diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index dea2b89ad0b..54c7314706d 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -101,6 +101,17 @@ describe('Electron runtime package contract', () => { expect(publishLinuxStep.with.command).toBe('${{ matrix.release_command }}') }) + it('keeps Linux postinstall repairing Chromium sandbox permissions', () => { + const afterInstallScript = readFileSync( + join(projectDir, 'resources/linux/packaging/after-install.sh'), + 'utf8' + ) + + expect(afterInstallScript).toContain('chrome-sandbox') + expect(afterInstallScript).toContain('chmod 4755 "$sandbox"') + expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') + }) + it('lets release-cut tag a version that is already present on main', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 1c96641d6bd..16f8e74a95d 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 1.4m + + downloads: 1.5m @@ -15,7 +15,7 @@ downloads downloads - 1.4m - 1.4m + 1.5m + 1.5m diff --git a/docs/github-tasks-close-reason-parity.md b/docs/github-tasks-close-reason-parity.md new file mode 100644 index 00000000000..dea16edfcec --- /dev/null +++ b/docs/github-tasks-close-reason-parity.md @@ -0,0 +1,137 @@ +# GitHub Tasks Close Reason Parity + +## Problem + +- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1025) renders `GHStatusCell` with only `Open` and `Closed`. +- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1064) sends `{ state: 'closed' }`, so the list path cannot choose GitHub close reasons. +- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1137) styles closed issues with rose/destructive color, while GitHub treats closing as a neutral/purple completion action rather than a delete/error action. +- [GitHubItemDialog.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/GitHubItemDialog.tsx:4960) has a separate issue-detail status popover; it must expose the same close reasons so opening an issue from Tasks does not fall back to the old Open/Closed-only UI. +- [GitHubIssueCommentComposer.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx:188) already supports close reasons in the detail composer, but the Tasks list does not. + +## Goal + +Bring the GitHub Tasks list status menu closer to GitHub: + +1. Closed issue UI must stop using destructive red styling. +2. Open issues must expose close-as-completed, close-as-not-planned, and close-as-duplicate from the status menu. +3. Duplicate closes must require a target issue number and pass it as `duplicateOf`. +4. Reopening still works from the same status menu. +5. GitHub issue detail metadata must use the same close-reason and duplicate-picker behavior as the Tasks row. + +## Non-goals + +- No provider-generic changes for GitLab, Linear, or Jira. +- No network-backed GitHub issue search picker in this pass; the duplicate picker uses the loaded Tasks cache plus an exact issue-number fallback. +- No schema changes or cache migration. +- No change to PR status behavior. +- No automatic cross-window broadcast beyond the existing cache/list refresh mechanics; this pass updates the current renderer optimistically and relies on normal refetch in other windows. + +## Design + +1. Add a small TaskPage GitHub status menu model in a named module, including option metadata, duplicate-target validation, and payload construction for close reasons. +2. Update `GHStatusCell` so `handleStateChange` accepts close options, optimistically patches only the issue `state`, and sends `stateReason` plus optional `duplicateOf`. +3. Route issue-state mutations by issue identity: + - If the row URL parses to `owner/repo`, use `github.project.updateIssueBySlug` / runtime `github.project.updateIssueBySlug`. This avoids closing the wrong repository for GitHub project/custom-source rows whose issue repo can differ from the caller repo. + - Otherwise keep the existing `github.updateIssue` path with `repoPath`/`repoId`/`sourceContext`. +4. Render the status popover with `Open`, `Close as completed`, `Close as not planned`, and `Close as duplicate` in both the Tasks row and issue-detail metadata sidebar. The duplicate row advances to a second-step picker with a back button, search field, loaded issue candidates, and an exact-number fallback. +5. Replace rose closed-state classes with token-based primary/ring color-mix classes so the closed pill is completion-like, not destructive. Keep the open-state green treatment for parity with the existing Tasks page unless a broader status-color pass changes it. +6. Keep the payload serializable for IPC/runtime/SSH: `{ state, stateReason, duplicateOf }` only contains strings and numbers. + +## API Notes + +- The local path in [issues.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/issues.ts:231) already closes issues with `gh issue close`. Preserve that model and pass: + - `--reason completed` for `stateReason: 'completed'` + - `--reason "not planned"` for `stateReason: 'not_planned'` + - `--duplicate-of ` for `stateReason: 'duplicate'` with `duplicateOf` +- The slug path in [mutations.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/project-view/mutations.ts:160) must not implement duplicates by PATCHing `duplicate_of` on `repos/{owner}/{repo}/issues/{n}`. `gh issue close --duplicate-of` is the supported CLI surface already used by the path-based mutation, so slug-addressed state changes should use `gh issue close/reopen --repo owner/repo` for state and reserve REST PATCH for title/body. +- When `stateReason: 'duplicate'` is sent without a valid `duplicateOf`, reject it before dispatch. Do not silently downgrade to plain closed or `--reason duplicate`, because GitHub's duplicate UX expects the target issue to be recorded. +- Keep `state_reason` REST PATCH only for non-duplicate close reasons if the implementation deliberately stays on REST for completed/not-planned. The simpler consistency target is to route all slug-addressed state changes through `gh issue close/reopen`. + +## Data Flow + +- User opens a GitHub issue status pill. +- Completed/not-planned click -> `handleStateChange('closed', { stateReason })`. +- Duplicate click -> second-step picker -> select a loaded issue or exact target number -> `handleStateChange('closed', { stateReason: 'duplicate', duplicateOf })`. +- Cell optimistically patches local state and store row state. +- Mutation goes through local IPC or runtime RPC with the same repo/source context. +- Failure reverts the local/store state and shows the existing error toast. +- Success records the existing `github-tasks` feature interaction and should invalidate or refresh the relevant work-items cache entry if the current filter can hide the row after the state changes (for example, an `is:open` filter). Without that, a closed row can remain visible until a later refetch. + +## Edge Cases + +- Duplicate target is blank, zero, negative, decimal, or the same issue number: keep the picker open and show inline validation after Enter. +- Duplicate target can be a different repository's issue only when the implementation accepts a URL; this pass takes a loaded same-repository issue or number, so validation and copy should describe it as an issue in the same repository. +- The row refreshes while the menu is open: reconcile the optimistic draft before paint as the current status cell already does. +- Rapid repeated choices: retain the existing request id guard so stale responses cannot revert a newer choice. +- SSH/runtime target: payload must remain serializable and use the existing runtime method. +- Multi-window consistency: do not assume another window's in-memory `workItemsCache` observes the optimistic patch. Ensure the mutation path updates GitHub authoritatively and that normal cache invalidation/refetch behavior eventually corrects other windows. +- Project/custom-source rows: prefer the parsed issue URL slug over the selected local repo when available, because a GitHub Project can contain issues from repositories other than the current worktree. +- PR rows and rows without a repo continue to render a non-editable status pill. +- Rows without a parsed slug and without a local repo/source context should stay non-editable; do not attempt a mutation from only an issue number. + +## Test Plan + +- Unit: menu model builds `{ state: 'closed', stateReason: 'completed' }`, `{ state: 'closed', stateReason: 'not_planned' }`, and duplicate payloads only for valid duplicate targets. +- Unit: duplicate validation rejects missing/self/non-positive/non-integer targets. +- Existing unit: optimistic status draft reconciliation remains unchanged. +- Main unit: `updateIssue` emits `gh issue close --reason completed`, `gh issue close --reason "not planned"`, `gh issue close --duplicate-of `, and `gh issue reopen`. +- Main unit: `updateIssueBySlug` uses `gh issue close/reopen --repo owner/repo` for state changes, rejects duplicate without `duplicateOf`, and does not send unsupported `duplicate_of` REST fields. +- Renderer unit: `GHStatusCell` uses slug-addressed mutation when `item.url` contains an owner/repo and falls back to `github.updateIssue` only when slug parsing is unavailable. +- Store/cache unit: successful close from an open-filtered Tasks list invalidates or refreshes the affected work-items cache so hidden-by-filter rows do not linger indefinitely. +- Electron validation: Tasks GitHub issue row status menu shows the new close options and closed issue pill is not red. +- Electron validation: duplicate close picker appears after the duplicate action and supports issue search/exact-number fallback. Do not submit a real close mutation against user data. +- Electron validation: GitHub issue detail status sidebar shows the same close options and duplicate picker as the list row. +- Electron/runtime smoke: same menu opens and validation works when the active runtime target is an SSH/environment source; avoid a real close mutation unless using disposable test data. + +## UI Quality Bar + +- Popover should feel like an action menu, not an error/destructive confirmation. +- Closed issue pill must be visually distinct from open but not red/destructive. +- Duplicate picker must fit in the popover at desktop and narrow widths without clipping. +- Hover, focus, disabled, and inline validation states use existing shadcn primitives/tokens. + +## Review Screenshots + +1. GitHub Tasks issue row with status popover open showing all close reasons. +2. Duplicate close second-step picker with search and issue candidates. +3. Closed issue row/pill showing non-red completion styling. +4. GitHub issue detail sidebar status popover showing all close reasons. +5. GitHub issue detail duplicate close second-step picker. +6. Adjacent smoke: GitHub Tasks header/search area still renders normally. + +## Rollout + +1. Add status menu model and tests. +2. Update `GHStatusCell` UI and mutation payload. +3. Run focused tests, typecheck, and lint. +4. Run UI quality review. +5. Validate in Electron and capture screenshots. + +## Lightweight Eng Review + +- Scope: kept to the GitHub Tasks status cell plus a small model module; no provider-wide or network-backed search-picker work. +- Architecture/data flow: preserves current renderer -> IPC/runtime mutation boundaries, but must route rows with parseable GitHub slugs through `updateIssueBySlug` so project/custom-source issues mutate the issue's own repository rather than the selected worktree repo. +- API feasibility: path-based mutations already use the supported `gh issue close --duplicate-of` CLI path; slug-addressed mutations need the same CLI close/reopen treatment instead of REST `duplicate_of`. +- Failure modes covered: + - Invalid duplicate targets stay local and do not mutate. + - Failed mutations revert optimistic state using the existing request id guard. + - Runtime/SSH remains on the existing serializable RPC method. + - Unsupported duplicate API shape is avoided by keeping duplicate close on `gh issue close --duplicate-of`. + - Rows filtered by open/closed status do not rely solely on optimistic patching; the relevant work-items cache must be invalidated/refetched after success. +- Test coverage required: + - Unit tests for close update payloads and duplicate validation. + - Main-process tests for both `updateIssue` and `updateIssueBySlug` close/reopen command generation. + - Renderer routing tests for slug-addressed versus repo-path-addressed issue rows. + - Cache invalidation/refetch coverage for status-filtered Tasks lists. + - Existing status draft tests for optimistic reconciliation. + - Electron screenshots for menu, duplicate validation, closed styling, and adjacent header smoke. +- Performance/blast radius: no material concern; one extra tiny model import and no new polling/IPC. +- UI quality bar: compact GitHub-like action menu, token-based non-destructive closed styling, no clipping or overlap. +- Required review screenshots: + 1. Status popover with close reasons. + 2. Duplicate second-step picker with search and issue candidates. + 3. Non-red closed status pill. + 4. Tasks header/search smoke. +- Residual risks: + - Duplicate target search is limited to loaded Tasks cache candidates, with exact-number fallback for unloaded same-repository issues. + - Other open Orca windows may show stale status until their normal GitHub Tasks data refresh runs. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7be0e3894ea..542dbf53a47 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4915,12 +4915,14 @@ export default function SessionScreen() { autoCorrect={false} spellCheck={false} smartInsertDelete={false} + // Why: iOS textContentType wins over autoComplete and can + // narrow the keyboard surface; keep IME switching available. + autoComplete="off" keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)} returnKeyType="default" blurOnSubmit={false} editable={canSend} importantForAutofill="no" - textContentType="none" /> ) : ( @@ -4946,6 +4948,9 @@ export default function SessionScreen() { autoCorrect={autocompleteEnabled} spellCheck={autocompleteEnabled} smartInsertDelete={false} + // Why: terminal commands are not autofill content, but the + // keyboard must stay default so non-Latin IMEs remain selectable. + autoComplete="off" keyboardType={getTerminalCommandKeyboardType( Platform.OS, autocompleteEnabled diff --git a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts index 412e9db664a..ac6ee0ff91f 100644 --- a/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts +++ b/mobile/src/terminal/terminal-ios-ime-keyboard.test.ts @@ -11,4 +11,9 @@ describe('terminal iOS IME keyboard', () => { expect(sessionRouteSource).not.toContain("'ascii-capable'") expect(sessionRouteSource).not.toContain('"ascii-capable"') }) + + it('does not put terminal keyboard capture behind iOS textContentType semantics', () => { + expect(sessionRouteSource).not.toContain('textContentType="none"') + expect(sessionRouteSource).toContain('autoComplete="off"') + }) }) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 86ea4dbc37e..da6c4d34cda 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -1508,6 +1508,7 @@ private final class TreeRenderer { var focusedElementId: Int? var truncated = false var maxDepthReached = false + private let reader = AXSnapshotReader() private var nextIndex = 0 static let maxNodes = 1200 static let maxDepth = 64 @@ -1530,22 +1531,26 @@ private final class TreeRenderer { } guard !ancestors.contains(where: { CFEqual($0, element) }) else { return } - let role = stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown" - let children = primaryChildren(element, role: role, windowBounds: windowBounds) - let value = valueString(element) - let placeholder = placeholderString(element) - let rawActions = actions(element) - let rowSummary = rowTextSummary(element, role: role) - let linkText = role == "AXLink" ? descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil + let role = reader.stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown" + let children = reader.primaryChildren(element, role: role, windowBounds: windowBounds) + let value = reader.valueString(element, role: role) + let placeholder = reader.placeholderString(element) + let rawActions = reader.actions(element) + let rowSummary = reader.rowTextSummary(element, role: role) + let roleDescription = reader.stringAttribute(element, kAXRoleDescriptionAttribute as String) + let title = reader.stringAttribute(element, kAXTitleAttribute as String) + let label = reader.stringAttribute(element, kAXDescriptionAttribute as String) + let url = reader.stringAttribute(element, kAXURLAttribute as String) + let linkText = role == "AXLink" ? reader.descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil let baseNode = SnapshotRenderNode( role: role, - roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String), - title: stringAttribute(element, kAXTitleAttribute as String), - label: stringAttribute(element, kAXDescriptionAttribute as String), + roleDescription: roleDescription, + title: title, + label: label, linkText: linkText, value: value, placeholder: placeholder, - url: stringAttribute(element, kAXURLAttribute as String), + url: url, traits: [], rawActions: rawActions, childCount: children.count, @@ -1553,19 +1558,19 @@ private final class TreeRenderer { ) let name = SnapshotRenderHeuristics.displayName(baseNode) let meaningful = SnapshotRenderHeuristics.meaningfulActions(rawActions, role: role) - let localFrame = frame(element, windowBounds: windowBounds) - let traits = traitsFor(element, role: role) - let webAreaDepth = webAreaDepth(role: role, ancestors: ancestors) - let summary = genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits) + let localFrame = reader.frame(element, windowBounds: windowBounds) + let traits = reader.traitsFor(element, role: role) + let webAreaDepth = reader.webAreaDepth(role: role, ancestors: ancestors) + let summary = reader.genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits) let node = SnapshotRenderNode( role: role, - roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String), - title: stringAttribute(element, kAXTitleAttribute as String), - label: stringAttribute(element, kAXDescriptionAttribute as String), + roleDescription: roleDescription, + title: title, + label: label, linkText: linkText, value: value, placeholder: placeholder, - url: stringAttribute(element, kAXURLAttribute as String), + url: url, traits: traits, rawActions: rawActions, childCount: children.count, @@ -1617,6 +1622,23 @@ private final class TreeRenderer { } } + private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? { + let childNodes = children.map { child in + let role = reader.stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown" + return SnapshotRenderNode( + role: role, + roleDescription: reader.stringAttribute(child, kAXRoleDescriptionAttribute as String), + title: reader.stringAttribute(child, kAXTitleAttribute as String), + label: reader.stringAttribute(child, kAXDescriptionAttribute as String), + value: reader.valueString(child, role: role), + traits: reader.traitsFor(child, role: role) + ) + } + // Why: browsers expose every open tab through AX; retaining only the active + // tab keeps snapshots focused on the current page instead of stale tab titles. + return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes) + } + private func compactRenderedBrowserTabs(parent: SnapshotRenderNode, startLine: Int, depth: Int) { guard SnapshotRenderHeuristics.roleText(parent) == "scroll area" else { return } let indent = String(repeating: "\t", count: depth) @@ -1647,44 +1669,301 @@ private final class TreeRenderer { } } -private func valueString(_ element: AXUIElement) -> String? { - if isSecureTextElement(element) { - return "[redacted]" +private final class AXSnapshotReader { + private enum CachedAttribute { + case missing + case found(CFTypeRef) } - if let string = stringAttribute(element, kAXValueAttribute as String) { - return string + + private final class ElementCache { + let element: AXUIElement + var loadedAttributeNames = false + var advertisedAttributes: Set? + var attributes: [String: CachedAttribute] = [:] + var actions: [String]? + var settable: [String: Bool] = [:] + + init(element: AXUIElement) { + self.element = element + } } - if let number = numberAttribute(element, kAXValueAttribute as String) { - return number.stringValue + + private var elementsByHash: [CFHashCode: [ElementCache]] = [:] + + func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? { + guard let value = copyAttribute(element, attribute) else { return nil } + if CFGetTypeID(value) == CFStringGetTypeID(), let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + if CFGetTypeID(value) == CFURLGetTypeID(), let url = value as? URL { + return url.absoluteString + } + return nil } - return nil -} -private func isSecureTextElement(_ element: AXUIElement) -> Bool { - let role = stringAttribute(element, kAXRoleAttribute as String)?.lowercased() ?? "" - let subrole = stringAttribute(element, kAXSubroleAttribute as String)?.lowercased() ?? "" - let title = stringAttribute(element, kAXTitleAttribute as String)?.lowercased() ?? "" - let description = stringAttribute(element, kAXDescriptionAttribute as String)?.lowercased() ?? "" - let placeholder = placeholderString(element)?.lowercased() ?? "" - let haystack = [role, subrole, title, description, placeholder].joined(separator: " ") - return haystack.contains("secure") || - haystack.contains("password") || - haystack.contains("passcode") || - haystack.contains("verification code") || - haystack.contains("one-time code") -} + func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? { + copyAttribute(element, attribute) as? Bool + } -private func placeholderString(_ element: AXUIElement) -> String? { - stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder") -} + func numberAttribute(_ element: AXUIElement, _ attribute: String) -> NSNumber? { + copyAttribute(element, attribute) as? NSNumber + } -private func traitsFor(_ element: AXUIElement, role: String) -> [String] { - var traits: [String] = [] - if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") } - if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") } - if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") } - if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") } - return traits + func copyArray(_ element: AXUIElement, _ attribute: String) -> [AXUIElement]? { + copyAttribute(element, attribute) as? [AXUIElement] + } + + func actions(_ element: AXUIElement) -> [String] { + let cache = cache(for: element) + if let actions = cache.actions { + return actions + } + var value: CFArray? + let actions = AXUIElementCopyActionNames(element, &value) == .success ? value as? [String] ?? [] : [] + cache.actions = actions + return actions + } + + func isSettable(_ element: AXUIElement, _ attribute: String) -> Bool { + let cache = cache(for: element) + if let cached = cache.settable[attribute] { + return cached + } + var settable = DarwinBoolean(false) + let value = AXUIElementIsAttributeSettable(element, attribute as CFString, &settable) == .success && settable.boolValue + cache.settable[attribute] = value + return value + } + + func frame(_ element: AXUIElement, windowBounds: CGRect) -> CGRect? { + guard let absolute = absoluteFrame(element) else { return nil } + return CGRect( + x: absolute.minX - windowBounds.minX, + y: absolute.minY - windowBounds.minY, + width: absolute.width, + height: absolute.height + ) + } + + func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] { + if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty { + return visibleRows(rows, parent: element, windowBounds: windowBounds) + } + return copyArray(element, kAXChildrenAttribute as String) ?? [] + } + + func valueString(_ element: AXUIElement, role: String) -> String? { + if isSecureTextElement(element, role: role) { + return "[redacted]" + } + if let string = stringAttribute(element, kAXValueAttribute as String) { + return string + } + if let number = numberAttribute(element, kAXValueAttribute as String) { + return number.stringValue + } + return nil + } + + func placeholderString(_ element: AXUIElement) -> String? { + stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder") + } + + func traitsFor(_ element: AXUIElement, role: String) -> [String] { + var traits: [String] = [] + if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") } + if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") } + if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") } + if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") } + return traits + } + + func genericTextSummary( + _ element: AXUIElement, + role: String, + name: String?, + actions: [String], + traits: [String] + ) -> String? { + guard (role == kAXGroupRole as String || role == kAXUnknownRole as String), + name == nil, + actions.isEmpty, + traits.isEmpty, + isPlainTextSubtree(element, maxDepth: 4) + else { + return nil + } + let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4) + guard texts.count >= 2 else { return nil } + let summary = texts.joined(separator: " ") + guard summary.count <= 220 else { return nil } + return summary + } + + func rowTextSummary(_ element: AXUIElement, role: String) -> String? { + guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil } + let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3) + guard !texts.isEmpty else { return nil } + return texts.joined(separator: " ") + } + + func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] { + var values: [String] = [] + var seen = Set() + + func collect(_ node: AXUIElement, depth: Int) { + guard values.count < limit, depth <= maxDepth else { return } + let role = stringAttribute(node, kAXRoleAttribute as String) ?? "" + if role == kAXStaticTextRole as String || role == "AXLink" { + for candidate in [ + stringAttribute(node, kAXValueAttribute as String), + stringAttribute(node, kAXTitleAttribute as String), + stringAttribute(node, kAXDescriptionAttribute as String), + ] { + guard let candidate else { continue } + let text = preview(candidate, maxLength: 80) + guard !text.isEmpty, seen.insert(text).inserted else { continue } + values.append(text) + if values.count >= limit { return } + } + } + for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { + collect(child, depth: depth + 1) + if values.count >= limit { return } + } + } + + collect(element, depth: 0) + return values + } + + func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? { + if role == "AXWebArea" { return 0 } + guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else { + return nil + } + return ancestors.count - index + } + + private func copyAttribute(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? { + let cache = cache(for: element) + if let cached = cache.attributes[attribute] { + switch cached { + case .missing: + return nil + case let .found(value): + return value + } + } + if let advertisedAttributes = advertisedAttributes(cache), + !SnapshotRenderHeuristics.supportsAttribute(attribute, advertisedAttributes: advertisedAttributes) { + cache.attributes[attribute] = .missing + return nil + } + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else { + cache.attributes[attribute] = .missing + return nil + } + cache.attributes[attribute] = .found(value) + return value + } + + private func advertisedAttributes(_ cache: ElementCache) -> Set? { + if cache.loadedAttributeNames { + return cache.advertisedAttributes + } + cache.loadedAttributeNames = true + var value: CFArray? + guard AXUIElementCopyAttributeNames(cache.element, &value) == .success, let attributes = value as? [String] else { + return nil + } + cache.advertisedAttributes = Set(attributes) + return cache.advertisedAttributes + } + + private func absoluteFrame(_ element: AXUIElement) -> CGRect? { + guard let positionValue = copyAttribute(element, kAXPositionAttribute as String), + let sizeValue = copyAttribute(element, kAXSizeAttribute as String) + else { + return nil + } + var point = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point), + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) + else { + return nil + } + return CGRect(origin: point, size: size) + } + + private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] { + guard let parentFrame = frame(parent, windowBounds: windowBounds) else { + return Array(rows.prefix(20)) + } + let visible = rows.filter { row in + guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false } + return rowFrame.intersects(parentFrame) + } + return Array((visible.isEmpty ? rows : visible).prefix(20)) + } + + private func isSecureTextElement(_ element: AXUIElement, role: String) -> Bool { + guard SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: role) else { + return false + } + let haystack = [ + role, + stringAttribute(element, kAXSubroleAttribute as String) ?? "", + stringAttribute(element, kAXTitleAttribute as String) ?? "", + stringAttribute(element, kAXDescriptionAttribute as String) ?? "", + placeholderString(element) ?? "", + ].joined(separator: " ").lowercased() + return haystack.contains("secure") || + haystack.contains("password") || + haystack.contains("passcode") || + haystack.contains("verification code") || + haystack.contains("one-time code") + } + + private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool { + var sawText = false + let allowedContainerRoles: Set = [ + kAXGroupRole as String, + kAXUnknownRole as String, + kAXStaticTextRole as String, + "AXLink", + "AXImage", + ] + + func visit(_ node: AXUIElement, depth: Int) -> Bool { + guard depth <= maxDepth else { return false } + let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown" + guard allowedContainerRoles.contains(role) else { return false } + if role == kAXStaticTextRole as String || role == "AXLink" { + sawText = true + } + guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false } + for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { + guard visit(child, depth: depth + 1) else { return false } + } + return true + } + + return visit(element, depth: 0) && sawText + } + + private func cache(for element: AXUIElement) -> ElementCache { + let hash = CFHash(element) + if let cache = elementsByHash[hash]?.first(where: { CFEqual($0.element, element) }) { + return cache + } + let cache = ElementCache(element: element) + elementsByHash[hash, default: []].append(cache) + return cache + } } private let valueSettableRoles: Set = [ @@ -1697,24 +1976,6 @@ private let valueSettableRoles: Set = [ kAXTextFieldRole as String, ] -private func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] { - if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty { - return visibleRows(rows, parent: element, windowBounds: windowBounds) - } - return copyArray(element, kAXChildrenAttribute as String) ?? [] -} - -private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] { - guard let parentFrame = frame(parent, windowBounds: windowBounds) else { - return Array(rows.prefix(20)) - } - let visible = rows.filter { row in - guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false } - return rowFrame.intersects(parentFrame) - } - return Array((visible.isEmpty ? rows : visible).prefix(20)) -} - private func usesRowsAsPrimaryChildren(role: String) -> Bool { [ kAXBrowserRole as String, @@ -1724,23 +1985,6 @@ private func usesRowsAsPrimaryChildren(role: String) -> Bool { ].contains(role) } -private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? { - let childNodes = children.map { child in - let role = stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown" - return SnapshotRenderNode( - role: role, - roleDescription: stringAttribute(child, kAXRoleDescriptionAttribute as String), - title: stringAttribute(child, kAXTitleAttribute as String), - label: stringAttribute(child, kAXDescriptionAttribute as String), - value: valueString(child), - traits: traitsFor(child, role: role) - ) - } - // Why: browsers expose every open tab through AX; retaining only the active - // tab keeps snapshots focused on the current page instead of stale tab titles. - return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes) -} - private func isDirectRenderedBrowserTabLine(_ line: String, indent: String) -> Bool { guard line.hasPrefix(indent), !line.dropFirst(indent.count).hasPrefix("\t") else { return false @@ -1761,100 +2005,6 @@ private func renderedElementIndex(_ line: String, indent: String) -> Int? { return Int(digits) } -private func genericTextSummary( - _ element: AXUIElement, - role: String, - name: String?, - actions: [String], - traits: [String] -) -> String? { - guard (role == kAXGroupRole as String || role == kAXUnknownRole as String), - name == nil, - actions.isEmpty, - traits.isEmpty, - isPlainTextSubtree(element, maxDepth: 4) - else { - return nil - } - let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4) - guard texts.count >= 2 else { return nil } - let summary = texts.joined(separator: " ") - guard summary.count <= 220 else { return nil } - return summary -} - -private func rowTextSummary(_ element: AXUIElement, role: String) -> String? { - guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil } - let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3) - guard !texts.isEmpty else { return nil } - return texts.joined(separator: " ") -} - -private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool { - var sawText = false - let allowedContainerRoles: Set = [ - kAXGroupRole as String, - kAXUnknownRole as String, - kAXStaticTextRole as String, - "AXLink", - "AXImage", - ] - - func visit(_ node: AXUIElement, depth: Int) -> Bool { - guard depth <= maxDepth else { return false } - let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown" - guard allowedContainerRoles.contains(role) else { return false } - if role == kAXStaticTextRole as String || role == "AXLink" { - sawText = true - } - guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false } - for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { - guard visit(child, depth: depth + 1) else { return false } - } - return true - } - - return visit(element, depth: 0) && sawText -} - -private func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] { - var values: [String] = [] - var seen = Set() - - func collect(_ node: AXUIElement, depth: Int) { - guard values.count < limit, depth <= maxDepth else { return } - let role = stringAttribute(node, kAXRoleAttribute as String) ?? "" - if role == kAXStaticTextRole as String || role == "AXLink" { - for candidate in [ - stringAttribute(node, kAXValueAttribute as String), - stringAttribute(node, kAXTitleAttribute as String), - stringAttribute(node, kAXDescriptionAttribute as String), - ] { - guard let candidate else { continue } - let text = preview(candidate, maxLength: 80) - guard !text.isEmpty, seen.insert(text).inserted else { continue } - values.append(text) - if values.count >= limit { return } - } - } - for child in copyArray(node, kAXChildrenAttribute as String) ?? [] { - collect(child, depth: depth + 1) - if values.count >= limit { return } - } - } - - collect(element, depth: 0) - return values -} - -private func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? { - if role == "AXWebArea" { return 0 } - guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else { - return nil - } - return ancestors.count - index -} - private func sanitize(_ value: String) -> String { value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ") } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift index 275738028cd..53205adf5d3 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SnapshotRendering.swift @@ -60,6 +60,20 @@ public struct SnapshotTabStripCompaction: Equatable { } public enum SnapshotRenderHeuristics { + public static func shouldProbeSecureTextMetadata(role: String) -> Bool { + let normalized = role.lowercased() + return normalized.contains("text") || + normalized.contains("field") || + normalized.contains("password") || + normalized.contains("search") || + normalized.contains("combo") + } + + public static func supportsAttribute(_ attribute: String, advertisedAttributes: Set?) -> Bool { + guard let advertisedAttributes else { return true } + return advertisedAttributes.contains(attribute) + } + public static func displayName(_ node: SnapshotRenderNode) -> String? { if let title = clean(node.title) { return title diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift index b85e70cc801..dce1bdc304c 100644 --- a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SnapshotRenderingTests.swift @@ -2,6 +2,24 @@ import XCTest @testable import OrcaComputerUseMacOSCore final class SnapshotRenderingTests: XCTestCase { + func testSkipsUnsupportedAdvertisedAttributes() { + let advertised: Set = ["AXRole", "AXChildren"] + + XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXRole", advertisedAttributes: advertised)) + XCTAssertFalse(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: advertised)) + } + + func testUnknownAttributeAdvertisementsStayPermissive() { + XCTAssertTrue(SnapshotRenderHeuristics.supportsAttribute("AXTitle", advertisedAttributes: nil)) + } + + func testSecureTextMetadataOnlyProbesTextLikeRoles() { + XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXTextField")) + XCTAssertTrue(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXSearchField")) + XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXGroup")) + XCTAssertFalse(SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: "AXButton")) + } + func testElidesAnonymousWrappers() { let node = SnapshotRenderNode(role: "AXGroup", childCount: 1) diff --git a/package.json b/package.json index 81822285236..8cb01bd38bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.92-rc.0", + "version": "1.4.95-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/resources/linux/packaging/after-install.sh b/resources/linux/packaging/after-install.sh index 393bfb6a89c..a5b598e2bf2 100755 --- a/resources/linux/packaging/after-install.sh +++ b/resources/linux/packaging/after-install.sh @@ -12,6 +12,13 @@ set -e link="/usr/bin/orca-ide" for dir in /opt/Orca /opt/orca-ide /opt/orca; do + sandbox="$dir/chrome-sandbox" + if [ -f "$sandbox" ]; then + # Why: packaged Linux installs must leave Chromium's sandbox helper usable + # on hosts where unprivileged user namespaces are unavailable. + chmod 4755 "$sandbox" || true + fi + shim="$dir/resources/bin/orca-ide" if [ -x "$shim" ]; then # Only manage our own symlink; never clobber an unrelated /usr/bin/orca-ide. diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index b36b2fd2109..8370ab87ca4 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -4,7 +4,7 @@ description: >- Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts, terminals, repos, automations, worktree comments, and the browser embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli", - "Orca worktree", "child worktree", "spawn codex/claude in a worktree", + "Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree", "read/wait/send Orca terminal", "terminal send", "Orca browser", or "control the browser inside Orca". Prefer this over raw `git worktree`, ad hoc PTYs, Playwright, or Computer Use when the task touches Orca-managed state. Use @@ -61,6 +61,7 @@ orca worktree create --name child-task --agent codex --prompt "hi" --json orca worktree create --name independent-task --no-parent --json orca worktree set --worktree id: --display-name "My Task" --json orca worktree set --worktree active --comment "reproduced bug; testing fix" --json +orca worktree set --worktree active --workspace-status in-review --json orca worktree rm --worktree id: --force --json ``` @@ -108,6 +109,8 @@ orca worktree set --worktree active --comment "fix implemented; running integrat Update after meaningful state changes such as repro, fix, validation, handoff, or blocker. Keep comments short/current; failures are best-effort unless Orca state was requested. +Card status uses `--workspace-status `; defaults are `todo`, `in-progress`, `in-review`, `completed`. + ## Terminals Common commands: diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index f65bb8eec6a..adfb2aa86db 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -7,11 +7,10 @@ import { parseRovoSessionFile } from './session-scanner-graph-parsers' import { parseKimiSessionFile } from './session-scanner-kimi-parser' -import { - parseClaudeSessionFile, - parseCodexSessionFile, - parseGeminiSessionFile -} from './session-scanner-primary-parsers' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers' +import { parseCodexSessionFile } from './session-scanner-codex-parser' import { parseCopilotSessionFile, parseCursorSessionFile, @@ -20,6 +19,15 @@ import { } from './session-scanner-secondary-parsers' import type { SessionFileCandidate } from './session-scanner-types' +/** + * Parse a single agent session file into an `AiVaultSession`. Routes to the + * appropriate agent-specific parser based on `candidate.agent`. For OpenCode + * SQLite candidates (synthetic `db#id` paths), routes to + * `parseOpenCodeSqliteSession` instead of the legacy JSON parser. + * @param candidate - The session file candidate to parse. + * @param platform - The platform to use for resume command generation. + * @returns The parsed `AiVaultSession`, or `null` if parsing fails. + */ export async function parseAgentSessionFile( candidate: SessionFileCandidate, platform: NodeJS.Platform @@ -35,8 +43,20 @@ export async function parseAgentSessionFile( return parseCopilotSessionFile(candidate.file, platform) case 'cursor': return parseCursorSessionFile(candidate.file, platform) - case 'opencode': + case 'opencode': { + // Why: OpenCode 1.17.x sessions are read from SQLite via a synthetic + // # candidate path. Legacy file-based sessions use + // real filesystem paths and fall through to the JSON parser. + const sqliteCandidate = splitOpenCodeSqliteCandidate(candidate.file.path) + if (sqliteCandidate) { + return parseOpenCodeSqliteSession({ + dbPath: sqliteCandidate.dbPath, + sessionId: sqliteCandidate.sessionId, + platform + }) + } return parseOpenCodeSessionFile(candidate.file, platform) + } case 'grok': return parseGrokSessionFile(candidate.file, platform) case 'hermes': diff --git a/src/main/ai-vault/session-scanner-codex-parser.test.ts b/src/main/ai-vault/session-scanner-codex-parser.test.ts new file mode 100644 index 00000000000..19581fc8761 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-parser.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { dirname, join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseCodexSessionFile } from './session-scanner-codex-parser' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +describe('parseCodexSessionFile', () => { + it('does not double-count usage when token count formats switch', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-token-switch-')) + tempRoots.push(root) + const sessionPath = join(root, 'sessions', '2026', '06', '18', 'rollout-token-switch.jsonl') + await mkdir(dirname(sessionPath), { recursive: true }) + + await writeFile( + sessionPath, + jsonLines([ + { + timestamp: '2026-06-18T10:00:00.000Z', + type: 'session_meta', + payload: { id: 'token-format-switch', cwd: '/repo/app' } + }, + { + timestamp: '2026-06-18T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Measure Codex token totals' }] + } + }, + { + timestamp: '2026-06-18T10:00:02.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + last_token_usage: { + input_tokens: 70, + cached_input_tokens: 20, + output_tokens: 30, + total_tokens: 100 + } + } + } + }, + { + timestamp: '2026-06-18T10:00:03.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 90, + cached_input_tokens: 25, + output_tokens: 60, + total_tokens: 150 + } + } + } + } + ]) + ) + + const sessionStat = await stat(sessionPath) + const session = await parseCodexSessionFile( + { + path: sessionPath, + mtimeMs: sessionStat.mtimeMs, + modifiedAt: sessionStat.mtime.toISOString() + }, + 'darwin', + root + ) + + expect(session?.totalTokens).toBe(150) + }) +}) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts new file mode 100644 index 00000000000..5435b2eaf1c --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -0,0 +1,273 @@ +import { createReadStream } from 'fs' +import { stat } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { createInterface } from 'readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types' +import { + asRecord, + extractContentText, + extractGitBranch, + extractModel, + extractString, + normalizeCodexUsage, + normalizeTitleText, + parseJsonObject, + subtractCodexUsage +} from './session-scanner-values' + +const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' + +type CodexSessionIndexTitleCacheEntry = { + signature: string + titles: Map +} + +const codexSessionIndexTitleCache = new Map>() + +export async function parseCodexSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + codexHome: string | null = null +): Promise { + const accumulator = createAccumulator({ + agent: 'codex', + file, + sessionId: sessionIdFromFileName(file.path) + }) + let previousTotals: CodexUsageSnapshot | null = null + + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + + updateTimeline(accumulator, extractString(record.timestamp)) + + const payload = asRecord(record.payload) + if (record.type === 'session_meta' && payload) { + if (isCodexWorkerSession(payload)) { + // Why: Codex writes internal worker/sub-agent transcripts into the same + // history tree; AI Vault should show user-started sessions only. + return null + } + const sessionId = extractString(payload.id) + if (sessionId) { + accumulator.sessionId = sessionId + } + const indexedTitle = + extractCodexSessionMetadataTitle(payload) ?? + (await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId)) + if (indexedTitle) { + accumulator.title = indexedTitle + } + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch + continue + } + + if (record.type === 'turn_context' && payload) { + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + continue + } + + if (!payload) { + continue + } + + if (record.type === 'response_item' && payload.type === 'message') { + accumulator.messageCount++ + if (payload.role === 'user' && !accumulator.title) { + accumulator.title = extractContentText(payload.content) + } + addPreviewContent( + accumulator, + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', + payload.content, + record.timestamp + ) + continue + } + + if (record.type !== 'event_msg') { + continue + } + + if (payload.type === 'user_message') { + accumulator.messageCount++ + if (!accumulator.title) { + accumulator.title = extractContentText(payload.message) + } + addPreviewContent(accumulator, 'user', payload.message, record.timestamp) + continue + } + + if (payload.type === 'agent_message') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) + continue + } + + if (payload.type !== 'token_count') { + continue + } + + const info = asRecord(payload.info) + if (!info) { + continue + } + const totalUsage = normalizeCodexUsage(info.total_token_usage) + const lastUsage = normalizeCodexUsage(info.last_token_usage) + let delta: CodexUsageSnapshot | null = null + if (totalUsage) { + delta = subtractCodexUsage(totalUsage, previousTotals) + previousTotals = totalUsage + } else if (lastUsage) { + delta = lastUsage + previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage + } + if (delta) { + accumulator.totalTokens += delta.totalTokens + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + } + + return finalizeSession(accumulator, platform, { codexHome }) +} + +function addCodexUsage( + base: CodexUsageSnapshot, + increment: CodexUsageSnapshot +): CodexUsageSnapshot { + return { + inputTokens: base.inputTokens + increment.inputTokens, + cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens, + outputTokens: base.outputTokens + increment.outputTokens, + reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens, + totalTokens: base.totalTokens + increment.totalTokens + } +} + +function extractCodexThreadSource(payload: Record): string | null { + return extractString(payload.thread_source) ?? extractString(payload.threadSource) +} + +function isCodexWorkerSession(payload: Record): boolean { + const threadSource = extractCodexThreadSource(payload) + if (threadSource) { + return threadSource.toLowerCase() !== 'user' + } + + const source = asRecord(payload.source) + return Boolean(asRecord(source?.subagent)) +} + +function extractCodexSessionMetadataTitle(payload: Record): string | null { + return ( + normalizeTitleText(extractString(payload.title) ?? '') ?? + normalizeTitleText(extractString(payload.thread_name) ?? '') ?? + normalizeTitleText(extractString(payload.threadName) ?? '') + ) +} + +async function readCodexSessionIndexTitle( + sessionFilePath: string, + codexHome: string | null, + sessionId: string +): Promise { + const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath) + if (!resolvedCodexHome) { + return null + } + const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome) + return titleBySessionId.get(sessionId) ?? null +} + +function codexHomeFromSessionFilePath(sessionFilePath: string): string | null { + let currentDir = dirname(sessionFilePath) + while (currentDir && dirname(currentDir) !== currentDir) { + if (basename(currentDir) === 'sessions') { + return dirname(currentDir) + } + currentDir = dirname(currentDir) + } + return null +} + +async function readCodexSessionIndexTitles(codexHome: string): Promise> { + const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE) + let signature: string + try { + const indexStat = await stat(indexPath) + signature = `${indexStat.size}:${indexStat.mtimeMs}` + } catch { + return new Map() + } + + const cached = codexSessionIndexTitleCache.get(codexHome) + if (cached) { + const entry = await cached + if (entry.signature === signature) { + return entry.titles + } + } + + const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({ + signature, + titles + })) + codexSessionIndexTitleCache.set(codexHome, pending) + return (await pending).titles +} + +async function readCodexSessionIndexTitlesFromDisk( + indexPath: string +): Promise> { + const titleBySessionId = new Map() + try { + const lines = createInterface({ + input: createReadStream(indexPath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + const sessionId = extractString(record.id) + const title = normalizeTitleText(extractString(record.thread_name) ?? '') + if (sessionId && title) { + titleBySessionId.set(sessionId, title) + } + } + } catch { + // Codex creates the index opportunistically; older homes may only have raw transcripts. + } + return titleBySessionId +} diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index b40b5f591ca..6f99151f059 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -109,6 +109,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), + opencodeDbPaths: [], grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), diff --git a/src/main/ai-vault/session-scanner-opencode-sources.ts b/src/main/ai-vault/session-scanner-opencode-sources.ts new file mode 100644 index 00000000000..24069c7a0f2 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sources.ts @@ -0,0 +1,73 @@ +import { readdir } from 'fs/promises' +import { homedir } from 'os' +import { dirname, join } from 'path' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { listOpenCodeDatabases } from '../opencode-usage/scanner' +import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery' +import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' + +const OPENCODE_STORAGE_DIR = join( + process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), + 'storage' +) + +export function opencodeDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + const storageDirs = opencodeStorageDirs(options, wslHomeDirs) + return storageDirs.map(async (storageDir, index) => + discoverOpenCodeSessions({ + storageDir, + dbPaths: await opencodeDbPathsForSource(options, wslHomeDirs, storageDir, index), + limitPerAgent: limit, + issues + }) + ) +} + +function opencodeStorageDirs( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[] +): string[] { + return [ + options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, + ...wslHomeDirs.map((homeDir) => join(homeDir, '.local', 'share', 'opencode', 'storage')) + ] +} + +async function opencodeDbPathsForSource( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + storageDir: string, + sourceIndex: number +): Promise { + if (options.opencodeDbPaths) { + return sourceIndex === 0 ? options.opencodeDbPaths : [] + } + // Why: custom OpenCode storage roots still keep SQLite DBs in the parent data dir. + if (sourceIndex === 0 && options.opencodeStorageDir) { + return listOpenCodeDatabasesInDirectory(dirname(storageDir)) + } + if (sourceIndex === 0) { + return listOpenCodeDatabases() + } + const wslHomeDir = wslHomeDirs[sourceIndex - 1] + return wslHomeDir + ? listOpenCodeDatabasesInDirectory(join(wslHomeDir, '.local', 'share', 'opencode')) + : [] +} + +async function listOpenCodeDatabasesInDirectory(dataDir: string): Promise { + try { + const entries = await readdir(dataDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name)) + .map((entry) => join(dataDir, entry.name)) + .sort() + } catch { + return [] + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts new file mode 100644 index 00000000000..4ae4ea22284 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -0,0 +1,215 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import Database from '../sqlite/sync-database' + +let tempRoots: string[] = [] +let tempDbDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + for (const dir of tempDbDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempRoots = [] + tempDbDirs = [] +}) + +function isolatedScanRoots(root: string) { + return { + claudeProjectsDir: join(root, 'claude-projects'), + codexSessionsDir: join(root, 'codex-sessions'), + geminiSessionsDir: join(root, 'gemini-sessions'), + copilotSessionsDir: join(root, 'copilot-sessions'), + cursorProjectsDir: join(root, 'cursor-projects'), + opencodeStorageDir: join(root, 'opencode-storage'), + opencodeDbPaths: [] as readonly string[], + grokSessionsDir: join(root, 'grok-sessions'), + devinTranscriptsDir: join(root, 'devin-transcripts'), + hermesSessionsDir: join(root, 'hermes-sessions'), + rovoSessionsDir: join(root, 'rovo-sessions'), + openclawStateDir: join(root, 'openclaw-state'), + openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), + piSessionsDir: join(root, 'pi-sessions'), + droidSessionsDir: join(root, 'droid-sessions'), + droidProjectsDir: join(root, 'droid-projects'), + kimiSessionsDir: join(root, 'kimi-sessions') + } +} + +function createTempOpenCodeDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-ai-vault-sqlite-')) + tempDbDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function applyOpenCodeSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_archived INTEGER, + model TEXT, + agent TEXT, + cost REAL DEFAULT 0 NOT NULL, + tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, + tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, + tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + `) +} + +describe('scanAiVaultSessions — OpenCode SQLite + legacy file coexistence', () => { + it('discovers SQLite sessions next to a custom OpenCode storage directory', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-custom-opencode-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const opencodeDataDir = join(root, 'custom-opencode') + const opencodeStorageDir = join(opencodeDataDir, 'storage') + await mkdir(opencodeStorageDir, { recursive: true }) + + const dbPath = join(opencodeDataDir, 'opencode.db') + const db = new Database(dbPath) + applyOpenCodeSchema(db) + db.prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('custom-db-session', 'proj-1', 'slug', '/tmp/custom-opencode', + 'Custom SQLite session', '1.0.0', + 1777634010000, 1777634011000, NULL, 'build', 0, + 8, 13, 21, 34, 0)` + ).run() + db.close() + + const result = await scanAiVaultSessions({ + ...roots, + opencodeStorageDir, + opencodeDbPaths: undefined, + platform: 'darwin', + limit: 50 + }) + + const session = result.sessions.find((s) => s.sessionId === 'custom-db-session') + expect(session).toBeDefined() + expect(session!.agent).toBe('opencode') + expect(session!.title).toBe('Custom SQLite session') + expect(session!.filePath).toBe(dbPath) + expect(session!.totalTokens).toBe(42) + }) + + it('surfaces SQLite sessions alongside legacy file sessions and dedups by sessionId', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-mixed-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + // Legacy file session under storage/session//.json + await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true }) + await mkdir(join(roots.opencodeStorageDir, 'message', 'legacy-session'), { recursive: true }) + await writeFile( + join(roots.opencodeStorageDir, 'session', 'project', 'legacy-session.json'), + JSON.stringify({ + id: 'legacy-session', + directory: '/tmp/legacy', + title: 'Legacy file session', + time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 } + }) + ) + await writeFile( + join(roots.opencodeStorageDir, 'message', 'legacy-session', 'msg_1.json'), + JSON.stringify({ + role: 'user', + summary: { title: 'Legacy file session' }, + time: { created: 1_777_634_000_000 }, + tokens: { input: 5, output: 2 } + }) + ) + + // SQLite session — same sessionId as the legacy file (dedup should keep SQLite) + const { db, path: dbPath } = createTempOpenCodeDb() + applyOpenCodeSchema(db) + db.prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('legacy-session', 'proj-1', 'slug', '/tmp/sqlite', 'SQLite session', '1.0.0', + 1777634002000, 1777634003000, ?, 'build', 0, + 100, 40, 10, 5, 0)` + ).run(JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' })) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES ('msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)` + ).run(JSON.stringify({ role: 'user', time: { created: 1_777_634_002_500 } })) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES ('prt_sql_1', 'msg_sql_1', 'legacy-session', 1777634002500, 1777634002500, ?)` + ).run(JSON.stringify({ type: 'text', text: 'sqlite hello' })) + db.close() + + // A second SQLite-only session + const { db: db2, path: dbPath2 } = createTempOpenCodeDb() + applyOpenCodeSchema(db2) + db2 + .prepare( + `INSERT INTO session (id, project_id, slug, directory, title, version, + time_created, time_updated, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES ('sqlite-only', 'proj-1', 'slug2', '/tmp/sqlite-only', 'SQLite only', '1.0.0', + 1777634004000, 1777634005000, NULL, 'build', 0, + 50, 20, 0, 0, 0)` + ) + .run() + db2.close() + + const result = await scanAiVaultSessions({ + ...roots, + opencodeDbPaths: [dbPath, dbPath2], + platform: 'darwin', + limit: 50 + }) + + const opencodeSessions = result.sessions.filter((s) => s.agent === 'opencode') + const sessionIds = opencodeSessions.map((s) => s.sessionId).sort() + expect(sessionIds).toEqual(['legacy-session', 'sqlite-only']) + + // Why: dedup keeps the SQLite entry (newer time_updated, source of truth) + const legacyEntry = opencodeSessions.find((s) => s.sessionId === 'legacy-session') + expect(legacyEntry).toBeDefined() + expect(legacyEntry!.title).toBe('SQLite session') + expect(legacyEntry!.cwd).toBe('/tmp/sqlite') + expect(legacyEntry!.filePath).toBe(dbPath) + expect(legacyEntry!.totalTokens).toBe(150) + expect(legacyEntry!.resumeCommand).toBe( + "cd '/tmp/sqlite' && opencode --session 'legacy-session'" + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts new file mode 100644 index 00000000000..3c465007992 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-discovery.ts @@ -0,0 +1,242 @@ +import { basename, extname, join } from 'path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { discoverFiles } from './session-scanner-discovery' +import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' +import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import type { + FileWithMtime, + SessionFileCandidate, + SessionFileDiscovery +} from './session-scanner-types' +import { errorMessage } from './session-scanner-values' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: keep the SQLite discovery + dedup layer separate from the parser so +// each file stays under the max-lines lint rule and the discovery layer can +// be tested in isolation. + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_created: number + time_updated: number + model_json: string | null + agent: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + db.pragma('query_only = ON') + return db +} + +function canReadOpenCodeSessions(db: SyncDatabase): boolean { + return ( + tableExists(db, 'session') && + columnExists(db, 'session', 'time_created') && + columnExists(db, 'session', 'time_updated') + ) +} + +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' +} + +function canCountOpenCodeMessages(db: SyncDatabase): boolean { + return ( + tableExists(db, 'message') && + columnExists(db, 'message', 'session_id') && + columnExists(db, 'message', 'data') + ) +} + +function buildSessionListQuery(db: SyncDatabase): string { + const modelSelect = sessionColumnSelect(db, 'model') + const agentSelect = sessionColumnSelect(db, 'agent') + const tokenColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'] + const tokenSelects = tokenColumns + .map((col) => `${columnExists(db, 'session', col) ? `s.${col}` : '0'} AS ${col}`) + .join(', ') + const costSelect = columnExists(db, 'session', 'cost') ? 's.cost' : '0' + const parentIdPredicate = columnExists(db, 'session', 'parent_id') + ? 'AND s.parent_id IS NULL' + : '' + const archivedPredicate = columnExists(db, 'session', 'time_archived') + ? 'AND s.time_archived IS NULL' + : '' + const messageCountSubquery = canCountOpenCodeMessages(db) + ? `(SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id + AND json_extract(m.data, '$.role') IN ('user','assistant'))` + : '0' + + return `SELECT s.id, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, + s.time_created, + s.time_updated, + ${modelSelect} AS model_json, ${agentSelect} AS agent, + ${tokenSelects}, ${costSelect} AS cost, + ${messageCountSubquery} AS message_count + FROM session s + WHERE 1=1 ${parentIdPredicate} ${archivedPredicate} + ORDER BY s.time_updated DESC + LIMIT ?` +} + +function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + return { + agent: 'opencode' as AiVaultAgent, + file: { + path: buildOpenCodeSqliteCandidatePath(dbPath, row.id), + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + codexHome: null + } +} + +function dedupeAndSortSqliteCandidates(candidates: SessionFileCandidate[]): SessionFileCandidate[] { + const candidatesBySessionId = new Map() + for (const candidate of candidates) { + const parsed = splitOpenCodeSqliteCandidate(candidate.file.path) + if (!parsed) { + continue + } + const previous = candidatesBySessionId.get(parsed.sessionId) + if (!previous || candidate.file.mtimeMs > previous.file.mtimeMs) { + candidatesBySessionId.set(parsed.sessionId, candidate) + } + } + return [...candidatesBySessionId.values()].sort((left, right) => { + return right.file.mtimeMs - left.file.mtimeMs + }) +} + +/** + * List OpenCode sessions from one or more SQLite databases as synthetic + * `SessionFileCandidate` entries. Each candidate's file path is a synthetic + * `#` string that the parser dispatcher routes to + * `parseOpenCodeSqliteSession`. Databases that lack the `session` table are + * silently skipped; errors are recorded as scan issues. + * @param args.dbPaths - Absolute paths to opencode.db files to scan. + * @param args.limit - Maximum number of sessions to return per database. + * @param args.issues - Collected scan issues to append errors to. + * @returns Array of synthetic candidates sorted by `time_updated` DESC. + */ +export async function listOpenCodeSqliteSessions(args: { + dbPaths: readonly string[] + limit: number + issues: AiVaultScanIssue[] +}): Promise { + const candidates: SessionFileCandidate[] = [] + for (const dbPath of args.dbPaths) { + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCodeSessions(db)) { + continue + } + const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[] + for (const row of rows) { + candidates.push(rowToCandidate(row, dbPath)) + } + } catch (err) { + args.issues.push({ + agent: 'opencode', + path: dbPath, + message: errorMessage(err) + }) + } finally { + db?.close() + } + } + return dedupeAndSortSqliteCandidates(candidates) +} + +// Why: extract the sessionId from a legacy file path like +// storage/session//.json. Falls back to the filename +// without extension when the opencode id format doesn't match a UUID. +function sessionIdFromLegacyFilePath(filePath: string): string { + return basename(filePath, extname(filePath)) +} + +/** + * Discover OpenCode sessions from both the legacy file layout and the SQLite + * DB, deduplicating at the file level before parsing. On mixed installs the + * same session may appear once via a stale legacy JSON file and once via the + * SQLite DB; SQLite is the source of truth on 1.17.x, so file-based entries + * whose sessionId matches a SQLite entry are dropped. Legacy installs without + * the `session` table fall through to the file scanner unchanged. + * @param args.storageDir - Root of the OpenCode storage directory (contains `session/` and `message/`). + * @param args.dbPaths - Absolute paths to opencode.db files to scan. + * @param args.limitPerAgent - Maximum number of candidates per source. + * @param args.issues - Collected scan issues to append errors to. + * @returns A `SessionFileDiscovery` with deduplicated file entries. + */ +export async function discoverOpenCodeSessions(args: { + storageDir: string + dbPaths: readonly string[] + limitPerAgent: number + issues: AiVaultScanIssue[] +}): Promise { + const [fileDiscovery, sqliteCandidates] = await Promise.all([ + discoverFiles({ + rootDir: join(args.storageDir, 'session'), + limit: args.limitPerAgent, + agent: 'opencode', + issues: args.issues, + extensions: ['.json'] + }), + listOpenCodeSqliteSessions({ + dbPaths: args.dbPaths, + limit: args.limitPerAgent, + issues: args.issues + }) + ]) + + const sqliteFiles = sqliteCandidates.map((c) => c.file) + // Why: on mixed installs the same OpenCode session may appear once via the + // SQLite DB and once via a stale legacy JSON file. SQLite is the source of + // truth on 1.17.x, so drop file-based duplicates when a SQLite entry with + // the same sessionId already exists. Deduping at the file level also avoids + // parsing the same session twice. + if (sqliteFiles.length === 0) { + return { + agent: 'opencode' as const, + rootDir: fileDiscovery.rootDir, + files: fileDiscovery.files + } + } + const sqliteSessionIds = new Set() + for (const file of sqliteFiles) { + const parsed = splitOpenCodeSqliteCandidate(file.path) + if (parsed) { + sqliteSessionIds.add(parsed.sessionId) + } + } + const dedupedFileDiscovery: FileWithMtime[] = [] + for (const file of fileDiscovery.files) { + if (!sqliteSessionIds.has(sessionIdFromLegacyFilePath(file.path))) { + dedupedFileDiscovery.push(file) + } + } + + return { + agent: 'opencode' as const, + rootDir: fileDiscovery.rootDir, + files: [...dedupedFileDiscovery, ...sqliteFiles] + } +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts new file mode 100644 index 00000000000..db9c60f0b70 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + buildOpenCodeSqliteCandidatePath, + looksLikeOpenCodeSqliteCandidate, + splitOpenCodeSqliteCandidate +} from './session-scanner-opencode-sqlite-paths' + +describe('splitOpenCodeSqliteCandidate', () => { + it('splits a synthetic db#sessionId path', () => { + const result = splitOpenCodeSqliteCandidate('/data/opencode.db#ses_abc') + expect(result).toEqual({ dbPath: '/data/opencode.db', sessionId: 'ses_abc' }) + }) + + it('splits a stable-db path', () => { + const result = splitOpenCodeSqliteCandidate('/data/opencode-stable.db#ses_xyz') + expect(result).toEqual({ dbPath: '/data/opencode-stable.db', sessionId: 'ses_xyz' }) + }) + + it('rejects a path whose db basename is not opencode*.db', () => { + expect(splitOpenCodeSqliteCandidate('/data/random.db#ses_abc')).toBeNull() + expect(splitOpenCodeSqliteCandidate('/data/notes.txt#ses_abc')).toBeNull() + }) + + it('rejects a path without a separator', () => { + expect(splitOpenCodeSqliteCandidate('/data/opencode.db')).toBeNull() + }) + + it('rejects an empty sessionId', () => { + expect(splitOpenCodeSqliteCandidate('/data/opencode.db#')).toBeNull() + }) +}) + +describe('looksLikeOpenCodeSqliteCandidate', () => { + it('returns true for a synthetic path', () => { + expect(looksLikeOpenCodeSqliteCandidate('/x/opencode.db#ses_1')).toBe(true) + }) + + it('returns false for a real filesystem path', () => { + expect(looksLikeOpenCodeSqliteCandidate('/x/storage/session/proj/ses_1.json')).toBe(false) + }) +}) + +describe('buildOpenCodeSqliteCandidatePath', () => { + it('joins dbPath and sessionId with #', () => { + expect(buildOpenCodeSqliteCandidatePath('/d/opencode.db', 'ses_1')).toBe('/d/opencode.db#ses_1') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts new file mode 100644 index 00000000000..fb1415af04e --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-paths.ts @@ -0,0 +1,56 @@ +import { basename } from 'path' + +// Why: keep the synthetic candidate-path helpers separate from the SQLite +// discovery/parser so both the scanner and the agent-parser dispatcher can +// import them without pulling in the SyncDatabase dependency. + +const OPENCODE_SQLITE_PATH_SEPARATOR = '#' + +/** + * Build a synthetic candidate path that encodes the SQLite DB path and session ID + * as `#`. Used by the discovery layer so SQLite-backed + * sessions flow through the same FileWithMtime pipeline as file-backed ones. + * @param dbPath - Absolute path to the opencode.db file. + * @param sessionId - The OpenCode session ID (primary key in the session table). + * @returns The synthetic candidate path string. + */ +export function buildOpenCodeSqliteCandidatePath(dbPath: string, sessionId: string): string { + return `${dbPath}${OPENCODE_SQLITE_PATH_SEPARATOR}${sessionId}` +} + +/** + * Parse a synthetic candidate path back into its DB path and session ID parts. + * Validates that the DB basename matches `opencode*.db` so real filesystem paths + * that happen to contain `#` are never misrouted to the SQLite parser. + * @param candidatePath - The synthetic path to parse. + * @returns `{ dbPath, sessionId }` if the path is a valid synthetic candidate, `null` otherwise. + */ +export function splitOpenCodeSqliteCandidate( + candidatePath: string +): { dbPath: string; sessionId: string } | null { + const separatorIndex = candidatePath.lastIndexOf(OPENCODE_SQLITE_PATH_SEPARATOR) + if (separatorIndex <= 0 || separatorIndex === candidatePath.length - 1) { + return null + } + const dbPath = candidatePath.slice(0, separatorIndex) + const sessionId = candidatePath.slice(separatorIndex + 1) + if (!dbPath || !sessionId) { + return null + } + // Why: OpenCode DB files are named opencode*.db; reject anything else so we + // never misroute a real filesystem path that happens to contain '#'. + if (!/^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/i.test(basename(dbPath))) { + return null + } + return { dbPath, sessionId } +} + +/** + * Type guard: returns `true` if the path is a valid synthetic OpenCode SQLite + * candidate path (i.e. `splitOpenCodeSqliteCandidate` would return non-null). + * @param candidatePath - The path to test. + * @returns `true` if the path is a synthetic SQLite candidate, `false` otherwise. + */ +export function looksLikeOpenCodeSqliteCandidate(candidatePath: string): boolean { + return splitOpenCodeSqliteCandidate(candidatePath) !== null +} diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts new file mode 100644 index 00000000000..96ca6ea361a --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.test.ts @@ -0,0 +1,499 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../sqlite/sync-database' +import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' +import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery' +import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +let tempDirs: string[] = [] + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } + tempDirs = [] +}) + +function createTempDb(): { db: Database.Database; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'orca-opencode-sqlite-')) + tempDirs.push(dir) + const path = join(dir, 'opencode.db') + return { db: new Database(path), path } +} + +function applyOpenCodeSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + share_url TEXT, + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + summary_diffs TEXT, + revert TEXT, + permission TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER, + workspace_id TEXT, + path TEXT, + agent TEXT, + model TEXT, + cost REAL DEFAULT 0 NOT NULL, + tokens_input INTEGER DEFAULT 0 NOT NULL, + tokens_output INTEGER DEFAULT 0 NOT NULL, + tokens_reasoning INTEGER DEFAULT 0 NOT NULL, + tokens_cache_read INTEGER DEFAULT 0 NOT NULL, + tokens_cache_write INTEGER DEFAULT 0 NOT NULL, + metadata TEXT + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT NOT NULL, + vcs TEXT, + name TEXT, + icon_url TEXT, + icon_color TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_initialized INTEGER, + sandboxes TEXT NOT NULL, + commands TEXT, + icon_url_override TEXT + ); + `) +} + +function applyMinimalOpenCodeSchema(db: Database.Database): void { + db.exec(`CREATE TABLE session ( + id TEXT PRIMARY KEY, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL + );`) +} + +function insertSession( + db: Database.Database, + args: { + id: string + title?: string + directory?: string + timeCreated: number + timeUpdated: number + parentId?: string | null + timeArchived?: number | null + model?: string | null + agent?: string | null + tokensInput?: number + tokensOutput?: number + tokensReasoning?: number + tokensCacheRead?: number + cost?: number + } +): void { + db.prepare( + `INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, + time_created, time_updated, time_archived, model, agent, cost, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + VALUES (?, 'proj-1', ?, ?, ?, ?, '1.0.0', + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, 0)` + ).run( + args.id, + args.parentId ?? null, + `slug-${args.id}`, + args.directory ?? '/tmp/opencode', + args.title ?? 'OpenCode title', + args.timeCreated, + args.timeUpdated, + args.timeArchived ?? null, + args.model ?? JSON.stringify({ id: 'glm-5.2', providerID: 'zai-coding-plan' }), + args.agent ?? 'build', + args.cost ?? 0, + args.tokensInput ?? 100, + args.tokensOutput ?? 40, + args.tokensReasoning ?? 10, + args.tokensCacheRead ?? 5 + ) +} + +function insertMessage( + db: Database.Database, + args: { + id: string + sessionId: string + role: 'user' | 'assistant' + timeCreated: number + summaryTitle?: string | null + summaryBody?: string | null + } +): void { + const data = JSON.stringify({ + role: args.role, + time: { created: args.timeCreated }, + agent: 'build', + summary: + args.summaryTitle || args.summaryBody + ? { title: args.summaryTitle ?? null, body: args.summaryBody ?? null } + : undefined + }) + db.prepare( + `INSERT INTO message (id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?)` + ).run(args.id, args.sessionId, args.timeCreated, args.timeCreated, data) +} + +function insertPart( + db: Database.Database, + args: { + id: string + messageId: string + sessionId: string + timeCreated: number + type?: 'text' | 'tool' | 'reasoning' + text?: string + } +): void { + const data = JSON.stringify({ + type: args.type ?? 'text', + text: args.text ?? 'hello world' + }) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(args.id, args.messageId, args.sessionId, args.timeCreated, args.timeCreated, data) +} +describe('listOpenCodeSqliteSessions', () => { + it('returns candidates sorted by time_updated desc via the synthesized mtimeMs', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_old', + title: 'Old', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertSession(db, { + id: 'ses_new', + title: 'New', + timeCreated: 1_777_634_002_000, + timeUpdated: 1_777_634_003_000 + }) + db.close() + + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues + }) + expect(issues).toEqual([]) + expect(candidates).toHaveLength(2) + expect(candidates[0].agent).toBe('opencode') + expect(candidates[0].file.mtimeMs).toBe(1_777_634_003_000) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_new')) + expect(candidates[1].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_old')) + }) + + it('dedups matching session ids across databases and keeps the newest row', async () => { + const { db: oldDb, path: oldPath } = createTempDb() + applyOpenCodeSchema(oldDb) + insertSession(oldDb, { + id: 'ses_duplicate', + title: 'Old duplicate', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + oldDb.close() + + const { db: newDb, path: newPath } = createTempDb() + applyOpenCodeSchema(newDb) + insertSession(newDb, { + id: 'ses_duplicate', + title: 'New duplicate', + timeCreated: 1_777_634_002_000, + timeUpdated: 1_777_634_003_000 + }) + newDb.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [oldPath, newPath], + limit: 10, + issues: [] + }) + expect(candidates).toHaveLength(1) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(newPath, 'ses_duplicate')) + }) + it('excludes archived and child sessions', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_normal', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertSession(db, { + id: 'ses_archived', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_002_000, + timeArchived: 1_777_634_002_500 + }) + insertSession(db, { + id: 'ses_child', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_003_000, + parentId: 'ses_normal' + }) + db.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues: [] + }) + expect(candidates.map((c) => c.file.path)).toEqual([ + buildOpenCodeSqliteCandidatePath(path, 'ses_normal') + ]) + }) + + it('returns [] when the session table is missing (legacy install)', async () => { + const { db, path } = createTempDb() + db.exec('CREATE TABLE other (id TEXT)') + db.close() + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues: [] + }) + expect(candidates).toEqual([]) + }) + + it('records an issue when the DB file does not exist', async () => { + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: ['/nonexistent/opencode.db'], + limit: 10, + issues + }) + expect(candidates).toEqual([]) + expect(issues).toHaveLength(1) + expect(issues[0].agent).toBe('opencode') + expect(issues[0].path).toBe('/nonexistent/opencode.db') + }) + + it('lists sessions from a minimal readable session table', async () => { + const { db, path } = createTempDb() + applyMinimalOpenCodeSchema(db) + db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run() + db.close() + + const issues: AiVaultScanIssue[] = [] + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: 10, + issues + }) + expect(issues).toEqual([]) + expect(candidates).toHaveLength(1) + expect(candidates[0].file.path).toBe(buildOpenCodeSqliteCandidatePath(path, 'ses_minimal')) + }) +}) + +describe('parseOpenCodeSqliteSession', () => { + it('builds an AiVaultSession with title, cwd, model, tokens, and resume command', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_1', + title: 'OpenCode title', + directory: '/tmp/opencode', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000, + tokensInput: 100, + tokensOutput: 40, + tokensReasoning: 10, + tokensCacheRead: 5, + cost: 0.01 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_1', + role: 'user', + timeCreated: 1_777_634_000_500, + summaryTitle: 'OpenCode title' + }) + insertPart(db, { + id: 'prt_1', + messageId: 'msg_1', + sessionId: 'ses_1', + timeCreated: 1_777_634_000_500, + text: 'Plan the work' + }) + insertMessage(db, { + id: 'msg_2', + sessionId: 'ses_1', + role: 'assistant', + timeCreated: 1_777_634_000_900 + }) + insertPart(db, { + id: 'prt_2', + messageId: 'msg_2', + sessionId: 'ses_1', + timeCreated: 1_777_634_001_000, + text: 'Done' + }) + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_1', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.agent).toBe('opencode') + expect(session!.sessionId).toBe('ses_1') + expect(session!.filePath).toBe(path) + expect(session!.title).toBe('OpenCode title') + expect(session!.cwd).toBe('/tmp/opencode') + expect(session!.model).toBe('glm-5.2') + expect(session!.totalTokens).toBe(150) + expect(session!.messageCount).toBe(2) + expect(session!.createdAt).toBe(new Date(1_777_634_000_000).toISOString()) + expect(session!.updatedAt).toBe(new Date(1_777_634_001_000).toISOString()) + expect(session!.resumeCommand).toBe("cd '/tmp/opencode' && opencode --session 'ses_1'") + expect(session!.previewMessages).toHaveLength(2) + expect(session!.previewMessages[0].text).toBe('Plan the work') + expect(session!.previewMessages[0].role).toBe('user') + expect(session!.previewMessages[1].text).toBe('Done') + expect(session!.previewMessages[1].role).toBe('assistant') + }) + + it('falls back to summary.body for title when session.title is empty', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_2', + title: '', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + insertMessage(db, { + id: 'msg_1', + sessionId: 'ses_2', + role: 'user', + timeCreated: 1_777_634_000_500, + summaryBody: 'fallback title from summary' + }) + insertPart(db, { + id: 'prt_1', + messageId: 'msg_1', + sessionId: 'ses_2', + timeCreated: 1_777_634_000_500, + text: 'hello' + }) + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_2', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.title).toBe('fallback title from summary') + }) + + it('returns null when the session id is not found', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_real', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000 + }) + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_missing', + platform: 'darwin' + }) + expect(session).toBeNull() + }) + + it('returns null when the DB has no session table', async () => { + const { db, path } = createTempDb() + db.exec('CREATE TABLE other (id TEXT)') + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_1', + platform: 'darwin' + }) + expect(session).toBeNull() + }) + + it('parses a minimal readable session table without optional columns or messages', async () => { + const { db, path } = createTempDb() + applyMinimalOpenCodeSchema(db) + db.prepare(`INSERT INTO session VALUES ('ses_minimal', 1777634000000, 1777634001000)`).run() + db.close() + + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_minimal', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.sessionId).toBe('ses_minimal') + expect(session!.filePath).toBe(path) + expect(session!.title).toBe('OpenCode ses_mini') + expect(session!.cwd).toBeNull() + expect(session!.model).toBeNull() + expect(session!.messageCount).toBe(0) + expect(session!.totalTokens).toBe(0) + expect(session!.previewMessages).toEqual([]) + }) + + it('extracts model from older modelID schema', async () => { + const { db, path } = createTempDb() + applyOpenCodeSchema(db) + insertSession(db, { + id: 'ses_3', + timeCreated: 1_777_634_000_000, + timeUpdated: 1_777_634_001_000, + model: JSON.stringify({ modelID: 'claude-sonnet-4-5' }) + }) + db.close() + const session = await parseOpenCodeSqliteSession({ + dbPath: path, + sessionId: 'ses_3', + platform: 'darwin' + }) + expect(session).not.toBeNull() + expect(session!.model).toBe('claude-sonnet-4-5') + }) +}) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts new file mode 100644 index 00000000000..f3a6dbcd055 --- /dev/null +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -0,0 +1,263 @@ +import type { AiVaultSession, AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' +import { + addPreviewMessage, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' +import { normalizeTitleText } from './session-scanner-values' +import SyncDatabase from '../sqlite/sync-database' +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' + +// Why: OpenCode 1.17.x migrated session storage from per-session JSON files +// to a single SQLite DB at ~/.local/share/opencode/opencode.db. This module +// parses individual sessions from the DB into AiVaultSession objects. The +// discovery layer (listing candidates) lives in +// session-scanner-opencode-sqlite-discovery.ts. + +const OPENCODE_SQLITE_PREVIEW_LIMIT = 5 + +type SessionRow = { + id: string + title: string | null + directory: string | null + time_created: number + time_updated: number + model_json: string | null + agent: string | null + tokens_input: number + tokens_output: number + tokens_reasoning: number + tokens_cache_read: number + cost: number + message_count: number +} + +type PreviewRow = { + role: string | null + part_data: string + time_created: number + summary_title: string | null + summary_body: string | null +} + +function openReadonlyDatabase(dbPath: string): SyncDatabase { + const db = new SyncDatabase(dbPath, { readonly: true, fileMustExist: true }) + // Why: belt-and-suspenders guard so a bug in the SELECT list can never + // mutate the user's opencode.db. + db.pragma('query_only = ON') + return db +} + +function canReadOpenCodeSessions(db: SyncDatabase): boolean { + return ( + tableExists(db, 'session') && + columnExists(db, 'session', 'time_created') && + columnExists(db, 'session', 'time_updated') + ) +} + +function sessionColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : 'NULL' +} + +function sessionNumberColumnSelect(db: SyncDatabase, columnName: string): string { + return columnExists(db, 'session', columnName) ? `s.${columnName}` : '0' +} + +function canCountOpenCodeMessages(db: SyncDatabase): boolean { + return ( + tableExists(db, 'message') && + columnExists(db, 'message', 'session_id') && + columnExists(db, 'message', 'data') + ) +} + +function buildSessionQuery(db: SyncDatabase): string { + const messageCountSubquery = canCountOpenCodeMessages(db) + ? `(SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id + AND json_extract(m.data, '$.role') IN ('user','assistant'))` + : '0' + return `SELECT s.id, + ${sessionColumnSelect(db, 'title')} AS title, + ${sessionColumnSelect(db, 'directory')} AS directory, + s.time_created, + s.time_updated, + ${sessionColumnSelect(db, 'model')} AS model_json, + ${sessionColumnSelect(db, 'agent')} AS agent, + ${sessionNumberColumnSelect(db, 'tokens_input')} AS tokens_input, + ${sessionNumberColumnSelect(db, 'tokens_output')} AS tokens_output, + ${sessionNumberColumnSelect(db, 'tokens_reasoning')} AS tokens_reasoning, + ${sessionNumberColumnSelect(db, 'tokens_cache_read')} AS tokens_cache_read, + ${sessionNumberColumnSelect(db, 'cost')} AS cost, + ${messageCountSubquery} AS message_count + FROM session s + WHERE s.id = ? + LIMIT 1` +} + +function extractModelId(modelJson: string | null): string | null { + if (!modelJson) { + return null + } + try { + const parsed = JSON.parse(modelJson) as unknown + const record = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + if (!record) { + return null + } + // Why: OpenCode 1.17.x stores model as {"id":"glm-5.2","providerID":"..."}. + // Older schemas used {"modelID":"..."}; accept both. + return ( + (typeof record.id === 'string' && record.id.trim()) || + (typeof record.modelID === 'string' && record.modelID.trim()) || + null + ) + } catch { + return null + } +} + +function mapPreviewRole(role: string | null): AiVaultSessionPreviewMessage['role'] { + if (role === 'user' || role === 'assistant' || role === 'system' || role === 'tool') { + return role + } + return 'unknown' +} + +function extractPartText(partData: string): string | null { + try { + const parsed = JSON.parse(partData) as unknown + const record = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + if (!record) { + return null + } + if (typeof record.text === 'string') { + return record.text + } + return null + } catch { + return null + } +} + +function buildPreviewQuery(db: SyncDatabase): string | null { + if ( + !canCountOpenCodeMessages(db) || + !tableExists(db, 'part') || + !columnExists(db, 'message', 'id') || + !columnExists(db, 'part', 'message_id') || + !columnExists(db, 'part', 'time_created') || + !columnExists(db, 'part', 'data') + ) { + return null + } + return `SELECT json_extract(m.data, '$.role') AS role, + p.data AS part_data, + p.time_created, + json_extract(m.data, '$.summary.title') AS summary_title, + json_extract(m.data, '$.summary.body') AS summary_body + FROM message m + JOIN part p ON p.message_id = m.id + WHERE m.session_id = ? + AND json_extract(m.data, '$.role') IN ('user','assistant') + AND json_extract(p.data, '$.type') = 'text' + ORDER BY p.time_created DESC + LIMIT ?` +} + +/** + * Parse a single OpenCode session from the SQLite database into an + * `AiVaultSession`. Reads session metadata (title, cwd, model, tokens, cost) + * and up to 5 preview messages by joining the `message` and `part` tables. + * The database is opened read-only with `PRAGMA query_only = ON` as a + * belt-and-suspenders guard against mutations. + * @param args.dbPath - Absolute path to the opencode.db file. + * @param args.sessionId - The session ID (primary key in the `session` table). + * @param args.platform - The platform to use for resume command generation. + * @returns The parsed `AiVaultSession`, or `null` if the session does not exist + * or the database lacks the required schema. + */ +export async function parseOpenCodeSqliteSession(args: { + dbPath: string + sessionId: string + platform: NodeJS.Platform +}): Promise { + const { dbPath, sessionId, platform } = args + let db: SyncDatabase | null = null + try { + db = openReadonlyDatabase(dbPath) + if (!canReadOpenCodeSessions(db)) { + return null + } + const row = db.prepare(buildSessionQuery(db)).get(sessionId) as SessionRow | undefined + if (!row || row.id !== sessionId) { + return null + } + + const mtimeMs = + typeof row.time_updated === 'number' && row.time_updated > 0 + ? row.time_updated + : row.time_created + // Why: discovery uses a synthetic db#session path only for parser routing. + // The UI's log open/reveal actions need a real filesystem path. + const accumulator = createAccumulator({ + agent: 'opencode', + file: { + path: dbPath, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString() + }, + sessionId + }) + accumulator.title = normalizeTitleText(row.title ?? '') + accumulator.cwd = row.directory + accumulator.model = extractModelId(row.model_json) + accumulator.totalTokens = + (row.tokens_input ?? 0) + (row.tokens_output ?? 0) + (row.tokens_reasoning ?? 0) + accumulator.messageCount = row.message_count ?? 0 + updateTimeline(accumulator, row.time_created) + updateTimeline(accumulator, row.time_updated) + + const previewSql = buildPreviewQuery(db) + if (previewSql) { + const previewRows = db + .prepare(previewSql) + .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT) as PreviewRow[] + // Why: query returns newest-first; push in chronological order so the + // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT + // messages. + for (let i = previewRows.length - 1; i >= 0; i--) { + const previewRow = previewRows[i] + if (!previewRow) { + continue + } + const text = extractPartText(previewRow.part_data) + if (!text) { + continue + } + addPreviewMessage(accumulator, { + role: mapPreviewRole(previewRow.role), + text, + timestamp: previewRow.time_created + }) + if (previewRow.role === 'user' && !accumulator.title) { + accumulator.title = + normalizeTitleText(previewRow.summary_title ?? '') || + normalizeTitleText(previewRow.summary_body ?? '') + } + } + } + + return finalizeSession(accumulator, platform) + } finally { + db?.close() + } +} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index fced9ab1f6a..dc062f5ab3e 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -2,7 +2,7 @@ import { createReadStream } from 'fs' import { readFile } from 'fs/promises' import { createInterface } from 'readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' import { addPreviewContent, createAccumulator, @@ -16,14 +16,10 @@ import { asRecord, claudeUsageTotal, extractContentText, - extractGitBranch, extractMessageText, - extractModel, extractString, - normalizeCodexUsage, normalizeTitleText, parseJsonObject, - subtractCodexUsage, tokenTotal } from './session-scanner-values' @@ -99,139 +95,6 @@ export async function parseClaudeSessionFile( return finalizeSession(accumulator, platform) } -export async function parseCodexSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform, - codexHome: string | null = null -): Promise { - const accumulator = createAccumulator({ - agent: 'codex', - file, - sessionId: sessionIdFromFileName(file.path) - }) - let previousTotals: CodexUsageSnapshot | null = null - - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - - for await (const line of lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - - updateTimeline(accumulator, extractString(record.timestamp)) - - const payload = asRecord(record.payload) - if (record.type === 'session_meta' && payload) { - if (isCodexWorkerSession(payload)) { - // Why: Codex writes internal worker/sub-agent transcripts into the same - // history tree; AI Vault should show user-started sessions only. - return null - } - const sessionId = extractString(payload.id) - if (sessionId) { - accumulator.sessionId = sessionId - } - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch - continue - } - - if (record.type === 'turn_context' && payload) { - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - continue - } - - if (!payload) { - continue - } - - if (record.type === 'response_item' && payload.type === 'message') { - accumulator.messageCount++ - if (payload.role === 'user' && !accumulator.title) { - accumulator.title = extractContentText(payload.content) - } - addPreviewContent( - accumulator, - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', - payload.content, - record.timestamp - ) - continue - } - - if (record.type !== 'event_msg') { - continue - } - - if (payload.type === 'user_message') { - accumulator.messageCount++ - if (!accumulator.title) { - accumulator.title = extractContentText(payload.message) - } - addPreviewContent(accumulator, 'user', payload.message, record.timestamp) - continue - } - - if (payload.type === 'agent_message') { - accumulator.messageCount++ - addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) - continue - } - - if (payload.type !== 'token_count') { - continue - } - - const info = asRecord(payload.info) - if (!info) { - continue - } - const totalUsage = normalizeCodexUsage(info.total_token_usage) - const lastUsage = normalizeCodexUsage(info.last_token_usage) - const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage - if (totalUsage) { - previousTotals = totalUsage - } - if (delta) { - accumulator.totalTokens += delta.totalTokens - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - } - - return finalizeSession(accumulator, platform, { codexHome }) -} - -function extractCodexThreadSource(payload: Record): string | null { - return extractString(payload.thread_source) ?? extractString(payload.threadSource) -} - -function isCodexWorkerSession(payload: Record): boolean { - const threadSource = extractCodexThreadSource(payload) - if (threadSource) { - return threadSource.toLowerCase() !== 'user' - } - - const source = asRecord(payload.source) - return Boolean(asRecord(source?.subagent)) -} - export async function parseGeminiSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index e5b5f7f371b..b4e376e606d 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -4,6 +4,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { uniqueCodexSessionsDirs } from './session-scanner-codex-paths' import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' +import { opencodeDiscoveries } from './session-scanner-opencode-sources' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' import { normalizePiSessionsDir } from './session-scanner-values' @@ -17,10 +18,6 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const OPENCODE_STORAGE_DIR = join( - process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), - 'storage' -) const GROK_SESSIONS_DIR = join( process.env.GROK_HOME?.trim() || join(homedir(), '.grok'), 'sessions' @@ -55,7 +52,11 @@ export async function discoverAiVaultSessionSources(args: { ...(options.additionalCodexSessionsDirs ?? []) ]) - return Promise.all([ + return Promise.all([ + // Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a + // SQLite DB. discoverOpenCodeSessions runs both the file scanner (legacy) + // and the SQLite scanner (1.17.x); dedup by sessionId happens inside. + ...opencodeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...claudeDiscoveries(options, wslHomeDirs, limitPerAgent, issues), ...codexDiscoveries(codexSessionsDirs, limitPerAgent, issues), ...standardDiscoveries(options, wslHomeDirs, limitPerAgent, issues), @@ -109,7 +110,6 @@ function standardDiscoveries( discoverFiles({ rootDir, limit, agent: 'copilot', issues, extensions: ['.jsonl'] }) ), ...cursorDiscoveries(options, wslHomeDirs, limit, issues), - ...opencodeDiscoveries(options, wslHomeDirs, limit, issues), ...grokDiscoveries(options, wslHomeDirs, limit, issues), ...devinDiscoveries(options, wslHomeDirs, limit, issues), ...hermesDiscoveries(options, wslHomeDirs, limit, issues), @@ -139,21 +139,6 @@ function cursorDiscoveries( ) } -function opencodeDiscoveries( - options: AiVaultScanOptions, - wslHomeDirs: readonly string[], - limit: number, - issues: AiVaultScanIssue[] -): Promise[] { - return sessionRootDirs( - join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'), - wslHomeDirs, - ['.local', 'share', 'opencode', 'storage', 'session'] - ).map((rootDir) => - discoverFiles({ rootDir, limit, agent: 'opencode', issues, extensions: ['.json'] }) - ) -} - function grokDiscoveries( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-text-normalization.ts b/src/main/ai-vault/session-scanner-text-normalization.ts index 8bd58ad6407..88eae73eb2e 100644 --- a/src/main/ai-vault/session-scanner-text-normalization.ts +++ b/src/main/ai-vault/session-scanner-text-normalization.ts @@ -5,7 +5,7 @@ const HIDDEN_BLOCK_CLOSE_SCAN_LIMIT = 256 * 1024 const HIDDEN_BLOCK_OPEN_TAG_SCAN_LIMIT = 512 const FIELD_BLANK_SCAN_LIMIT = 1024 -const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions for' +const AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions' const XML_INSTRUCTIONS_PREFIX = '' const HIDDEN_TEXT_BLOCKS = [ diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index e5330391f0e..fc7cf389b7d 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -14,6 +14,9 @@ export type AiVaultScanOptions = { copilotSessionsDir?: string cursorProjectsDir?: string opencodeStorageDir?: string + // Why: OpenCode 1.17.x stores sessions in SQLite; tests inject a temp DB + // here so they don't depend on the real ~/.local/share/opencode. + opencodeDbPaths?: readonly string[] grokSessionsDir?: string devinTranscriptsDir?: string hermesSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index ae0bc7ce106..a4c5cf6540f 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -21,6 +21,9 @@ function isolatedScanRoots(root: string) { copilotSessionsDir: join(root, 'copilot-sessions'), cursorProjectsDir: join(root, 'cursor-projects'), opencodeStorageDir: join(root, 'opencode-storage'), + // Why: prevent the SQLite scanner from picking up the real + // ~/.local/share/opencode/opencode.db during tests. + opencodeDbPaths: [] as readonly string[], grokSessionsDir: join(root, 'grok-sessions'), devinTranscriptsDir: join(root, 'devin-transcripts'), hermesSessionsDir: join(root, 'hermes-sessions'), @@ -110,7 +113,7 @@ describe('scanAiVaultSessions', () => { type: 'message', role: 'user', content: [ - { type: 'text', text: '# AGENTS.md instructions for /repo/app ' } + { type: 'text', text: '# AGENTS.md instructions\n\nrepo policy' } ] } }), @@ -162,6 +165,15 @@ describe('scanAiVaultSessions', () => { }) ].join('\n') ) + await writeFile( + join(root, 'session_index.jsonl'), + jsonLines([ + { + id: '019f0000-1111-7222-8333-444444444444', + thread_name: 'Indexed Codex resume picker title' + } + ]) + ) const result = await scanAiVaultSessions({ ...roots, @@ -171,7 +183,7 @@ describe('scanAiVaultSessions', () => { expect(result.issues).toEqual([]) expect(result.sessions).toHaveLength(2) expect(result.sessions.map((session) => session.title).sort()).toEqual([ - 'Fix the resume picker filters', + 'Indexed Codex resume picker title', 'Vault polish pass' ]) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index f07a769562d..484dbbc6339 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -21,6 +21,15 @@ const DEFAULT_LIMIT = 1000 const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000 const SESSION_PARSE_CONCURRENCY = 8 +/** + * Scan all supported AI agent session stores and return a unified, sorted, + * deduplicated list of sessions for the AI Vault panel. Discovers sessions + * from file-based stores (Claude, Codex, Gemini, etc.) and SQLite-based + * stores (OpenCode 1.17.x). Results are sorted by session sort time DESC + * and truncated to `limit`. + * @param options - Optional scan configuration (limits, custom dirs, platform). + * @returns The list of sessions, scan issues, and a timestamp. + */ export async function scanAiVaultSessions( options: AiVaultScanOptions = {} ): Promise { diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index e2b8c155caa..75afb290310 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1424,6 +1424,38 @@ describe('createPtySubprocess', () => { expect(spawnMock).not.toHaveBeenCalled() }) + it('normalizes MSYS drive cwd before spawning daemon PowerShell on Windows', () => { + const proc = mockPtyProcess() + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + spawnMock.mockImplementation((_shell, _args, options) => { + if (options.cwd === '/c/Users/alice/project') { + throw new Error('Cannot create process, error code: 267') + } + return proc + }) + + try { + createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + cwd: '/c/Users/alice/project', + shellOverride: 'powershell.exe' + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell.exe', + POWERSHELL_OSC133_COMMAND_ARGS, + expect.objectContaining({ cwd: 'C:\\Users\\alice\\project' }) + ) + }) + it('validates the requested Windows cwd before launching WSL on Windows', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { value: 'win32' }) diff --git a/src/main/git/status-upstream-negative-cache.test.ts b/src/main/git/status-upstream-negative-cache.test.ts new file mode 100644 index 00000000000..0dcc4f4bcc6 --- /dev/null +++ b/src/main/git/status-upstream-negative-cache.test.ts @@ -0,0 +1,329 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitStreamStdout: async ( + args: string[], + options: { onStdout: (chunk: string) => boolean | void } + ) => { + const { stdout } = await gitExecFileAsyncMock(args) + const stoppedEarly = options.onStdout(stdout ?? '') === true + return { stoppedEarly } + }, + gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({ + ...env, + GIT_OPTIONAL_LOCKS: '0' + }) +})) + +vi.mock('fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('fs', () => ({ + existsSync: existsSyncMock +})) + +import { + clearEffectiveUpstreamNegativeStatusCache, + clearEffectiveUpstreamStatusCacheForTests, + getEffectiveUpstreamStatusCacheCountForTests, + getEffectiveUpstreamStatusGenerationCountForTests, + getStatus +} from './status' + +describe('local upstream negative cache', () => { + beforeEach(() => { + clearEffectiveUpstreamStatusCacheForTests() + existsSyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + readFileMock.mockReset() + existsSyncMock.mockReturnValue(false) + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + }) + + it('bypasses a cached negative result for strict status reads', async () => { + let originBranchExists = false + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + throw new Error('missing remote branch') + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const first = await getStatus('/repo') + originBranchExists = true + const automatic = await getStatus('/repo') + const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + + expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(automatic.upstreamStatus).toEqual(first.upstreamStatus) + expect(strict.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + }) + + it('keeps an older automatic negative probe from overwriting a strict positive result', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const automatic = getStatus('/repo') + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + const staleAutomatic = await automatic + const nextAutomatic = await getStatus('/repo') + + expect(strict.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(staleAutomatic.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus) + }) + + it('does not trim generation for an unresolved automatic probe', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const branchQueue = [ + 'feature', + 'feature', + ...Array.from({ length: 512 }, (_, index) => `other-${index}`), + 'feature' + ] + let currentBranch = 'feature' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = branchQueue.shift() ?? currentBranch + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const automatic = getStatus('/repo') + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + for (let index = 0; index < 512; index += 1) { + await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await getStatus('/repo') + + expect(strict.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('does not trim generation for a cleared automatic probe before it settles', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const branchQueue = [ + 'feature', + ...Array.from({ length: 512 }, (_, index) => `other-${index}`), + 'feature' + ] + let currentBranch = 'feature' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = branchQueue.shift() ?? currentBranch + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const automatic = getStatus('/repo') + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + clearEffectiveUpstreamNegativeStatusCache({ worktreePath: '/repo', branchName: 'feature' }) + for (let index = 0; index < 512; index += 1) { + await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await getStatus('/repo') + + expect(nextAutomatic.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds effective-upstream negative entries', async () => { + let branchIndex = 0 + let currentBranch = 'feature-0' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = `feature-${branchIndex}` + branchIndex += 1 + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + throw new Error('missing remote branch') + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + for (let index = 0; index < 513; index += 1) { + await getStatus('/repo') + } + + expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(512) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds write-generation entries from positive strict probes', async () => { + let branchIndex = 0 + let currentBranch = 'feature-0' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = `feature-${branchIndex}` + branchIndex += 1 + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${currentBranch}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${currentBranch}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + for (let index = 0; index < 513; index += 1) { + await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + } + + expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(0) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) + }) +}) diff --git a/src/main/git/status-upstream-probe-churn.test.ts b/src/main/git/status-upstream-probe-churn.test.ts index b59f88dee8a..ddf216b3f9d 100644 --- a/src/main/git/status-upstream-probe-churn.test.ts +++ b/src/main/git/status-upstream-probe-churn.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Repro command: // pnpm exec vitest run --config config/vitest.config.ts src/main/git/status-upstream-probe-churn.test.ts -t "missing-upstream polling churn" @@ -51,6 +51,10 @@ describe('getStatus missing-upstream polling churn', () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/Initi-Project\n') }) + afterEach(() => { + vi.useRealTimers() + }) + it('does not repeat failed effective-upstream probes for a branch with no upstream', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args.includes('status')) { @@ -87,6 +91,39 @@ describe('getStatus missing-upstream polling churn', () => { expect(sameNameOriginProbeCalls).toHaveLength(1) }) + it('keeps failed effective-upstream probes cached beyond thirty seconds', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head Initi-Project\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'Initi-Project\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error("fatal: no upstream configured for branch 'Initi-Project'") + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project')) { + throw new Error('missing remote branch') + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + await getStatus('/repo') + vi.setSystemTime(31_000) + await getStatus('/repo') + + const upstreamProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { + const args = getGitArgs(call) + return args[0] === 'rev-parse' && args.includes('HEAD@{u}') + }) + + expect(upstreamProbeCalls).toHaveLength(1) + }) + it('coalesces concurrent effective-upstream probes for a branch with no upstream', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args.includes('status')) { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 4097adaf1e6..8876fb18f53 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -52,7 +52,8 @@ import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES const BULK_CHUNK_SIZE = 100 -const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 30_000 +const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 +const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512 type EffectiveUpstreamStatusCacheEntry = { expiresAt: number @@ -61,10 +62,22 @@ type EffectiveUpstreamStatusCacheEntry = { const effectiveUpstreamStatusCache = new Map() const effectiveUpstreamStatusInFlight = new Map>() +const retiredEffectiveUpstreamStatusInFlight = new Map>() +const effectiveUpstreamStatusWriteGeneration = new Map() export function clearEffectiveUpstreamStatusCacheForTests(): void { effectiveUpstreamStatusCache.clear() effectiveUpstreamStatusInFlight.clear() + retiredEffectiveUpstreamStatusInFlight.clear() + effectiveUpstreamStatusWriteGeneration.clear() +} + +export function getEffectiveUpstreamStatusCacheCountForTests(): number { + return effectiveUpstreamStatusCache.size +} + +export function getEffectiveUpstreamStatusGenerationCountForTests(): number { + return effectiveUpstreamStatusWriteGeneration.size } export type GetStatusOptions = GitRuntimeOptions & { @@ -74,6 +87,7 @@ export type GetStatusOptions = GitRuntimeOptions & { * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. */ limit?: number + bypassEffectiveUpstreamNegativeCache?: boolean } /** @@ -168,7 +182,8 @@ export async function getStatus( cacheKey, worktreePath, branchName, - options + options, + options.bypassEffectiveUpstreamNegativeCache === true ) } catch { // Why: git status polling should not fail just because the richer @@ -282,6 +297,64 @@ function getEffectiveUpstreamStatusCacheKey( return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0') } +export function clearEffectiveUpstreamNegativeStatusCache(identity: { + worktreePath: string + branchName: string + upstreamName?: string + options?: GitRuntimeOptions +}): void { + const cacheKey = getEffectiveUpstreamStatusCacheKey( + identity.worktreePath, + identity.branchName, + identity.upstreamName, + identity.options + ) + retireEffectiveUpstreamStatusProbe(cacheKey) + effectiveUpstreamStatusCache.delete(cacheKey) + effectiveUpstreamStatusInFlight.delete(cacheKey) + effectiveUpstreamStatusWriteGeneration.set( + cacheKey, + (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 + ) +} + +function retireEffectiveUpstreamStatusProbe(cacheKey: string): void { + const retiredProbe = effectiveUpstreamStatusInFlight.get(cacheKey) + if (!retiredProbe) { + return + } + retiredEffectiveUpstreamStatusInFlight.set(cacheKey, retiredProbe) + void retiredProbe + .finally(() => { + if (retiredEffectiveUpstreamStatusInFlight.get(cacheKey) === retiredProbe) { + retiredEffectiveUpstreamStatusInFlight.delete(cacheKey) + trimEffectiveUpstreamStatusGeneration() + } + }) + .catch(() => undefined) +} + +function hasPendingEffectiveUpstreamStatusProbe(cacheKey: string): boolean { + return ( + effectiveUpstreamStatusInFlight.has(cacheKey) || + retiredEffectiveUpstreamStatusInFlight.has(cacheKey) + ) +} + +function trimEffectiveUpstreamStatusGeneration(): void { + for (const cacheKey of effectiveUpstreamStatusWriteGeneration.keys()) { + if ( + effectiveUpstreamStatusWriteGeneration.size <= MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES + ) { + break + } + if (hasPendingEffectiveUpstreamStatusProbe(cacheKey)) { + continue + } + effectiveUpstreamStatusWriteGeneration.delete(cacheKey) + } +} + function readCachedEffectiveUpstreamStatus( cacheKey: string, now: number @@ -301,12 +374,18 @@ function rememberEffectiveUpstreamStatus( cacheKey: string, status: GitUpstreamStatus, now: number, - probedSameNameOriginRef: boolean + probedSameNameOriginRef: boolean, + writeGeneration: number ): void { // Why: hasConfiguredPushTarget gates a write action. Re-probe it each poll // rather than keeping a stale positive target after branch config changes. if (status.hasUpstream || status.hasConfiguredPushTarget) { effectiveUpstreamStatusCache.delete(cacheKey) + effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1) + trimEffectiveUpstreamStatusGeneration() + return + } + if ((effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { return } if (!probedSameNameOriginRef) { @@ -318,41 +397,58 @@ function rememberEffectiveUpstreamStatus( status, expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS }) + while (effectiveUpstreamStatusCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { + const oldest = effectiveUpstreamStatusCache.keys().next() + if (oldest.done) { + break + } + effectiveUpstreamStatusCache.delete(oldest.value) + effectiveUpstreamStatusWriteGeneration.delete(oldest.value) + } + trimEffectiveUpstreamStatusGeneration() } async function readOrProbeEffectiveUpstreamStatus( cacheKey: string, worktreePath: string, branchName: string, - options: GitRuntimeOptions = {} + options: GitRuntimeOptions = {}, + bypassCache = false ): Promise { - const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) - if (cached) { - return cached - } + if (!bypassCache) { + const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) + if (cached) { + return cached + } - const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey) - if (inFlight) { - return inFlight + const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } } // Why: source-control mount and root git refresh can overlap during startup. // Coalesce the richer upstream probe so a stable missing ref fails once. + const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 const probe = probeEffectiveUpstreamStatus(worktreePath, branchName, options).then((result) => { rememberEffectiveUpstreamStatus( cacheKey, result.status, Date.now(), - result.probedSameNameOriginRef + result.probedSameNameOriginRef, + writeGeneration ) return result.status }) - effectiveUpstreamStatusInFlight.set(cacheKey, probe) + if (!bypassCache) { + effectiveUpstreamStatusInFlight.set(cacheKey, probe) + } try { return await probe } finally { if (effectiveUpstreamStatusInFlight.get(cacheKey) === probe) { effectiveUpstreamStatusInFlight.delete(cacheKey) + trimEffectiveUpstreamStatusGeneration() } } } diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 8493317a4c0..9e6e5eb92bf 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -110,7 +110,8 @@ import { updatePRTitle, _getMergeQueueCacheSizeForTests, _resetOwnerRepoCache, - _resetMergeQueueCacheForTests + _resetMergeQueueCacheForTests, + __resetTrackedUpstreamBranchCacheForTests } from './client' describe('checkOrcaStarred', () => { @@ -179,6 +180,7 @@ describe('getPRForBranch', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + __resetTrackedUpstreamBranchCacheForTests() }) it('queries GitHub by head branch when the remote is on github.com', async () => { @@ -1160,6 +1162,104 @@ describe('getPRForBranch', () => { }) }) + it('does not repeat missing tracked-upstream probes during PR refresh polling', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockRejectedValue( + new Error("fatal: no upstream configured for branch 'no-pr-branch'") + ) + + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch') + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(1) + }) + + it('coalesces concurrent missing tracked-upstream probes', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockImplementation(async () => { + await Promise.resolve() + throw new Error("fatal: no upstream configured for branch 'no-pr-branch'") + }) + + await Promise.all([ + getPRForBranch('/repo-root', 'no-pr-branch'), + getPRForBranch('/repo-root', 'no-pr-branch'), + getPRForBranch('/repo-root', 'no-pr-branch') + ]) + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(1) + }) + + it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockRejectedValue( + new Error("fatal: no upstream configured for branch 'no-pr-branch'") + ) + + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + await getPRForBranch('/repo-root', 'no-pr-branch') + await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('no-pr-branch@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(2) + expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' }) + expect(trackedUpstreamCalls[1][1]).toEqual({ + cwd: '/repo-root', + wslDistro: 'Ubuntu' + }) + }) + + it('rechecks missing tracked-upstream probes after the null-cache TTL expires', async () => { + vi.useFakeTimers() + try { + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'")) + .mockResolvedValueOnce({ stdout: 'origin/contributor/original\n', stderr: '' }) + + await getPRForBranch('/repo-root', 'feature') + await vi.advanceTimersByTimeAsync(30_001) + await getPRForBranch('/repo-root', 'feature') + + const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => + (args as string[]).includes('feature@{upstream}') + ) + expect(trackedUpstreamCalls).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('uses the tracked upstream remote owner for fork branch lookup', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6bf0976bac3..158c28ef4d6 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2273,6 +2273,20 @@ type TrackedUpstreamBranch = { branchName: string } +const TRACKED_UPSTREAM_NULL_CACHE_TTL_MS = 30_000 + +type TrackedUpstreamNullCacheEntry = { + expiresAt: number +} + +const trackedUpstreamNullCache = new Map() +const trackedUpstreamInFlight = new Map>() + +export function __resetTrackedUpstreamBranchCacheForTests(): void { + trackedUpstreamNullCache.clear() + trackedUpstreamInFlight.clear() +} + function parseTrackedUpstreamBranch( upstreamRef: string, branchName: string @@ -2289,6 +2303,65 @@ async function getTrackedUpstreamBranch( branchName: string, connectionId?: string | null, localGitOptions: { wslDistro?: string } = {} +): Promise { + // Why: branches without configured upstreams are stable misses during PR + // polling; cache only nulls so positive PR discovery stays fresh. + const cacheKey = getTrackedUpstreamBranchCacheKey( + repoPath, + branchName, + connectionId, + localGitOptions + ) + const now = Date.now() + const cachedNull = trackedUpstreamNullCache.get(cacheKey) + if (cachedNull && cachedNull.expiresAt > now) { + return null + } + if (cachedNull) { + trackedUpstreamNullCache.delete(cacheKey) + } + + const inFlight = trackedUpstreamInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + + const probe = probeTrackedUpstreamBranch(repoPath, branchName, connectionId, localGitOptions) + trackedUpstreamInFlight.set(cacheKey, probe) + try { + const result = await probe + if (result) { + trackedUpstreamNullCache.delete(cacheKey) + } else { + trackedUpstreamNullCache.set(cacheKey, { + expiresAt: now + TRACKED_UPSTREAM_NULL_CACHE_TTL_MS + }) + } + return result + } finally { + if (trackedUpstreamInFlight.get(cacheKey) === probe) { + trackedUpstreamInFlight.delete(cacheKey) + } + } +} + +function getTrackedUpstreamBranchCacheKey( + repoPath: string, + branchName: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} +): string { + const runtimeKey = connectionId + ? `ssh:${connectionId}` + : `local:${localGitOptions.wslDistro ?? 'host'}` + return [runtimeKey, repoPath, branchName].join('\0') +} + +async function probeTrackedUpstreamBranch( + repoPath: string, + branchName: string, + connectionId?: string | null, + localGitOptions: { wslDistro?: string } = {} ): Promise { const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`] try { diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 1c8e5c5596f..f68726b395a 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -251,4 +251,52 @@ describe('issue source operations', () => { { cwd: '/repo-root' } ) }) + + it('closes issues with completed, not planned, and duplicate reasons', async () => { + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await expect( + updateIssue('/repo-root', 924, { state: 'closed', stateReason: 'completed' }) + ).resolves.toEqual({ ok: true }) + await expect( + updateIssue('/repo-root', 925, { state: 'closed', stateReason: 'not_planned' }) + ).resolves.toEqual({ ok: true }) + await expect( + updateIssue('/repo-root', 926, { + state: 'closed', + stateReason: 'duplicate', + duplicateOf: 99 + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['issue', 'close', '924', '--repo', 'stablyai/orca', '--reason', 'completed'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['issue', 'close', '925', '--repo', 'stablyai/orca', '--reason', 'not planned'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['issue', 'close', '926', '--repo', 'stablyai/orca', '--duplicate-of', '99'], + { cwd: '/repo-root' } + ) + }) + + it('reopens issues through gh issue reopen', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + await expect(updateIssue('/repo-root', 924, { state: 'open' })).resolves.toEqual({ + ok: true + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'reopen', '924', '--repo', 'stablyai/orca'], + { cwd: '/repo-root' } + ) + }) }) diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 8af4ee10b4b..7dbb30f30d6 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -12,6 +12,7 @@ import type { } from '../../shared/types' import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' type QueueEntry = { key: string @@ -64,6 +65,7 @@ const BACKGROUND_BUDGET_MAX = 20 const POST_PUSH_DELAY_MS = 2_500 const BACKOFF_BASE_MS = 60_000 const BACKOFF_MAX_MS = 15 * 60_000 +const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 let sequence = 0 let draining = false @@ -74,6 +76,12 @@ const errorBackoff = new Map() let lastBackgroundStartAt = 0 const visibleByWindow = new Map }>() let outcomeObserver: PRRefreshOutcomeObserver | null = null +const diagnosticsCounters = { + enqueued: 0, + coalesced: 0, + skipped: 0, + backgroundPauses: 0 +} export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { outcomeObserver = observer @@ -94,6 +102,27 @@ function removeInvisibleVisibleRefreshes(): void { } } +function recordPRRefreshQueueDiagnostic( + event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', + reason: GitHubPRRefreshReason, + skippedReason?: GitHubPRRefreshSkippedReason +): void { + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_queue', + coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, + minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, + data: { + event, + reason, + ...(skippedReason ? { skippedReason } : {}), + enqueued: diagnosticsCounters.enqueued, + coalesced: diagnosticsCounters.coalesced, + skipped: diagnosticsCounters.skipped, + backgroundPauses: diagnosticsCounters.backgroundPauses + } + }) +} + export function clearVisiblePRRefreshWindow(windowId: number): void { if (!visibleByWindow.delete(windowId)) { return @@ -480,6 +509,8 @@ async function drainQueue(): Promise { const budgetDelay = isBudgetedQueueEntry(next) ? nextBudgetDelay() : 0 if (budgetDelay > 0) { + diagnosticsCounters.backgroundPauses += 1 + recordPRRefreshQueueDiagnostic('background-pause', next.reason) scheduleDrain(budgetDelay) return } @@ -488,6 +519,8 @@ async function drainQueue(): Promise { const aliases = Array.from(next.aliases.values()) const skippedReason = validateCandidate(next.candidate) if (skippedReason) { + diagnosticsCounters.skipped += 1 + recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason) broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) continue } @@ -578,6 +611,8 @@ export function enqueuePRRefresh( const skippedReason = validateCandidate(candidate) if (skippedReason) { removeQueuedAliasForInvalidCandidate(key, alias) + diagnosticsCounters.skipped += 1 + recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason) broadcast({ aliases: [alias], reason, @@ -592,6 +627,8 @@ export function enqueuePRRefresh( const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) if (existing) { existing.aliases.set(alias.cacheKey, alias) + diagnosticsCounters.coalesced += 1 + recordPRRefreshQueueDiagnostic('coalesced', reason) const shouldPromoteExisting = priority > existing.priority || isManual(reason) || @@ -604,6 +641,8 @@ export function enqueuePRRefresh( existing.windowId = windowId ?? existing.windowId } } else { + diagnosticsCounters.enqueued += 1 + recordPRRefreshQueueDiagnostic('enqueued', reason) queue.set(key, { key, candidate, diff --git a/src/main/github/pr-refresh-validation-backoff.test.ts b/src/main/github/pr-refresh-validation-backoff.test.ts new file mode 100644 index 00000000000..fd4211783ec --- /dev/null +++ b/src/main/github/pr-refresh-validation-backoff.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { recordCoalescedCrashBreadcrumbMock } = vi.hoisted(() => ({ + recordCoalescedCrashBreadcrumbMock: vi.fn() +})) + +vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ + recordCoalescedCrashBreadcrumb: recordCoalescedCrashBreadcrumbMock +})) + +import { + clearPRRefreshValidationBackoffForTests, + getPRRefreshValidationBackoffCountForTests, + notePRRefreshValidationDenial +} from './pr-refresh-validation-backoff' + +describe('PR refresh validation backoff', () => { + beforeEach(() => { + clearPRRefreshValidationBackoffForTests() + recordCoalescedCrashBreadcrumbMock.mockReset() + }) + + it('backs off repeated automatic validation denials until the TTL expires', () => { + const identity = { + repoId: 'repo-1', + repoPath: '/workspace/missing', + reason: 'unknown-repo' as const + } + + expect(notePRRefreshValidationDenial(identity, 0)).toBe('validation-denied') + expect(notePRRefreshValidationDenial(identity, 60_000)).toBe('validation-backoff') + expect(notePRRefreshValidationDenial(identity, 5 * 60_000 + 1)).toBe('validation-denied') + }) + + it('bounds validation backoff entries', () => { + for (let index = 0; index < 257; index += 1) { + notePRRefreshValidationDenial( + { + repoId: `repo-${index}`, + repoPath: `/workspace/missing-${index}`, + reason: 'unknown-repo' + }, + index + ) + } + + expect(getPRRefreshValidationBackoffCountForTests()).toBe(256) + }) + + it('records path-safe diagnostic breadcrumbs', () => { + notePRRefreshValidationDenial( + { + repoId: 'repo-1', + repoPath: '/Users/alice/private/project', + reason: 'repo-path-mismatch' + }, + 0 + ) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'pr_refresh_validation_skip', + data: expect.objectContaining({ + reason: 'repo-path-mismatch', + result: 'recorded', + token: expect.any(String) + }) + }) + ) + const payload = recordCoalescedCrashBreadcrumbMock.mock.calls[0]?.[0] + expect(JSON.stringify(payload)).not.toContain('/Users/alice/private/project') + }) +}) diff --git a/src/main/github/pr-refresh-validation-backoff.ts b/src/main/github/pr-refresh-validation-backoff.ts new file mode 100644 index 00000000000..ab03a064d84 --- /dev/null +++ b/src/main/github/pr-refresh-validation-backoff.ts @@ -0,0 +1,107 @@ +import { createHash } from 'crypto' +import { resolve } from 'path' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' + +const VALIDATION_BACKOFF_TTL_MS = 5 * 60_000 +const MAX_VALIDATION_BACKOFF_ENTRIES = 256 +const VALIDATION_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export type PRRefreshValidationDenialReason = + | 'unknown-repo' + | 'repo-path-mismatch' + | 'host-mismatch' + +type ValidationBackoffIdentity = { + repoId?: string | null + repoPath: string + reason: PRRefreshValidationDenialReason +} + +type ValidationBackoffEntry = { + expiresAt: number +} + +type ValidationBackoffCounters = { + recorded: number + skipped: number + expired: number +} + +const validationBackoff = new Map() +const counters: ValidationBackoffCounters = { + recorded: 0, + skipped: 0, + expired: 0 +} + +function validationIdentityKey(identity: ValidationBackoffIdentity): string { + return [identity.repoId ?? '', resolve(identity.repoPath), identity.reason].join('\0') +} + +function validationIdentityToken(key: string): string { + return createHash('sha256').update(key).digest('hex').slice(0, 12) +} + +function evictOldestValidationBackoffEntries(): void { + while (validationBackoff.size > MAX_VALIDATION_BACKOFF_ENTRIES) { + const oldest = validationBackoff.keys().next() + if (oldest.done) { + break + } + validationBackoff.delete(oldest.value) + } +} + +function recordValidationBreadcrumb( + reason: PRRefreshValidationDenialReason, + result: 'recorded' | 'backoff', + token: string +): void { + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_validation_skip', + coalesceKey: `pr-refresh-validation:${reason}:${token}`, + minIntervalMs: VALIDATION_BREADCRUMB_MIN_INTERVAL_MS, + data: { + reason, + result, + token, + recorded: counters.recorded, + skipped: counters.skipped, + expired: counters.expired + } + }) +} + +export function notePRRefreshValidationDenial( + identity: ValidationBackoffIdentity, + nowMs = Date.now() +): 'validation-denied' | 'validation-backoff' { + const key = validationIdentityKey(identity) + const existing = validationBackoff.get(key) + const token = validationIdentityToken(key) + if (existing && existing.expiresAt > nowMs) { + counters.skipped += 1 + recordValidationBreadcrumb(identity.reason, 'backoff', token) + return 'validation-backoff' + } + if (existing) { + counters.expired += 1 + validationBackoff.delete(key) + } + counters.recorded += 1 + validationBackoff.set(key, { expiresAt: nowMs + VALIDATION_BACKOFF_TTL_MS }) + evictOldestValidationBackoffEntries() + recordValidationBreadcrumb(identity.reason, 'recorded', token) + return 'validation-denied' +} + +export function clearPRRefreshValidationBackoffForTests(): void { + validationBackoff.clear() + counters.recorded = 0 + counters.skipped = 0 + counters.expired = 0 +} + +export function getPRRefreshValidationBackoffCountForTests(): number { + return validationBackoff.size +} diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts new file mode 100644 index 00000000000..9d8cabca7f8 --- /dev/null +++ b/src/main/github/project-view/mutations.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { acquireMock, ghExecFileAsyncMock, releaseMock, runRestMock } = vi.hoisted(() => ({ + acquireMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + releaseMock: vi.fn(), + runRestMock: vi.fn() +})) + +vi.mock('./internals', () => ({ + acquire: acquireMock, + release: releaseMock, + extractExecError: (err: unknown) => ({ + stderr: err instanceof Error ? err.message : String(err), + stdout: '' + }), + ghExecFileAsync: ghExecFileAsyncMock, + rateLimitGuard: () => ({ blocked: false }), + noteRateLimitSpend: vi.fn(), + classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }), + rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }), + runGraphql: vi.fn(), + runRest: runRestMock, + validateSlugArgs: (owner: string, repo: string) => + owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } }, + assertPositiveInt: (value: number, name: string) => + Number.isInteger(value) && value > 0 + ? { ok: true, value } + : { ok: false, error: { type: 'validation_error', message: `${name} invalid` } } +})) + +import { updateIssueBySlug } from './mutations' + +describe('updateIssueBySlug', () => { + beforeEach(() => { + acquireMock.mockReset().mockResolvedValue(undefined) + ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + releaseMock.mockReset() + runRestMock.mockReset().mockResolvedValue({ ok: true, data: {} }) + }) + + it('closes slug-addressed issues with completed and not planned reasons via gh issue close', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'closed', stateReason: 'completed' } + }) + ).resolves.toEqual({ ok: true }) + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 13, + updates: { state: 'closed', stateReason: 'not_planned' } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'], + { encoding: 'utf-8' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['issue', 'close', '13', '--repo', 'acme/widgets', '--reason', 'not planned'], + { encoding: 'utf-8' } + ) + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('closes slug-addressed duplicate issues with --duplicate-of', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'closed', stateReason: 'duplicate', duplicateOf: 99 } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'close', '12', '--repo', 'acme/widgets', '--duplicate-of', '99'], + { encoding: 'utf-8' } + ) + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('rejects duplicate closes without a target before spawning gh', async () => { + const result = await updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'closed', stateReason: 'duplicate' } + }) + + expect(result).toMatchObject({ + ok: false, + error: { type: 'validation_error' } + }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('reopens slug-addressed issues via gh issue reopen', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'open' } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'reopen', '12', '--repo', 'acme/widgets'], + { encoding: 'utf-8' } + ) + }) +}) diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index cd72c9e8ce1..e2da3dae068 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -183,17 +183,63 @@ export async function updateIssueBySlug( removeAssignees } = args.updates - // Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}. + if (duplicateOf !== undefined && (state !== 'closed' || stateReason !== 'duplicate')) { + return { + ok: false, + error: { + type: 'validation_error', + message: 'Duplicate target is only valid when closing as duplicate.' + } + } + } + if (state === 'closed' && stateReason === 'duplicate' && duplicateOf === undefined) { + return { + ok: false, + error: { + type: 'validation_error', + message: 'Duplicate target issue number is required.' + } + } + } + if (duplicateOf !== undefined) { + const duplicate = assertPositiveInt(duplicateOf, 'duplicateOf') + if (!duplicate.ok) { + return { ok: false, error: duplicate.error } + } + } + + // Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}. + // State uses gh issue close/reopen so duplicate closes can record a target. // Labels/assignees go through their dedicated endpoints. const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` + if (state !== undefined) { + const stateArgs = + state === 'closed' + ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] + : ['issue', 'reopen', String(args.number), '--repo', `${args.owner}/${args.repo}`] + if (state === 'closed') { + if (stateReason === 'completed') { + stateArgs.push('--reason', 'completed') + } else if (stateReason === 'not_planned') { + stateArgs.push('--reason', 'not planned') + } else if (stateReason === 'duplicate') { + stateArgs.push('--duplicate-of', String(duplicateOf)) + } + } + await acquire() + try { + await ghExecFileAsync(stateArgs, { encoding: 'utf-8' }) + } catch (err) { + const { stderr, stdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, stdout) } + } finally { + release() + } + } + // 1) PATCH body - if ( - title !== undefined || - body !== undefined || - state !== undefined || - stateReason !== undefined - ) { + if (title !== undefined || body !== undefined) { const patchArgs: string[] = ['-X', 'PATCH', base] if (title !== undefined) { patchArgs.push('--raw-field', `title=${title}`) @@ -201,15 +247,6 @@ export async function updateIssueBySlug( if (body !== undefined) { patchArgs.push('--raw-field', `body=${body}`) } - if (state !== undefined) { - patchArgs.push('--raw-field', `state=${state}`) - } - if (stateReason !== undefined) { - patchArgs.push('--raw-field', `state_reason=${stateReason}`) - } - if (duplicateOf !== undefined) { - patchArgs.push('--raw-field', `duplicate_of=${duplicateOf}`) - } const r = await runRest(patchArgs) if (!r.ok) { return { ok: false, error: r.error } diff --git a/src/main/ipc/agent-detection-shell-path.ts b/src/main/ipc/agent-detection-shell-path.ts new file mode 100644 index 00000000000..42569755037 --- /dev/null +++ b/src/main/ipc/agent-detection-shell-path.ts @@ -0,0 +1,16 @@ +import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' +import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' + +export async function hydrateShellPathForAgentDetection( + context?: PreflightRuntimeContext +): Promise { + if (getPreflightWslTarget(context)) { + return + } + // Why: remote runtime servers may inherit a sparse daemon/SSH PATH even + // though the user's shell can run the agents. + const hydration = await hydrateShellPath() + if (hydration.ok) { + mergePathSegments(hydration.segments) + } +} diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index f8670c4ebd3..dd7ec9c2697 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -885,6 +885,36 @@ describe('registerFilesystemHandlers', () => { expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true }) }) + it('forwards upstream-negative-cache bypass through local and SSH git status IPC', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const sshProvider = { + getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + bypassEffectiveUpstreamNegativeCache: true + }) + await handlers.get('git:status')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + bypassEffectiveUpstreamNegativeCache: true + }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + includeIgnored: false, + bypassEffectiveUpstreamNegativeCache: true + }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + includeIgnored: false, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + it('checks ignored paths through local and SSH git providers', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js']) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 6f803ca737c..021ee8f4f1d 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -829,9 +829,19 @@ export function registerFilesystemHandlers( 'git:status', async ( _event, - args: { worktreePath: string; connectionId?: string; includeIgnored?: boolean } + args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + } ): Promise => { - const options = { includeIgnored: args.includeIgnored ?? false } + const options = { + includeIgnored: args.includeIgnored ?? false, + ...(args.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}) + } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 687c0f8d0a7..d889120497c 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -158,6 +158,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({ })) import { registerGitHubHandlers } from './github' +import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff' type HandlerMap = Record unknown> @@ -237,6 +238,7 @@ describe('registerGitHubHandlers', () => { refreshPRNowMock.mockReset() reportVisiblePRRefreshCandidatesMock.mockReset() setPRRefreshOutcomeObserverMock.mockReset() + clearPRRefreshValidationBackoffForTests() for (const key of Object.keys(handlers)) { delete handlers[key] } @@ -293,6 +295,156 @@ describe('registerGitHubHandlers', () => { expect(getIssueMock).not.toHaveBeenCalled() }) + it('returns typed automatic PR refresh validation skips without enqueueing', async () => { + registerGitHubHandlers(store as never, stats as never) + const candidate = { + cacheKey: 'missing::feature/test', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/test', + repoKind: 'git' as const + } + + const first = await handlers['gh:enqueuePRRefresh'](null, { + candidate, + reason: 'active', + priority: 80 + }) + const second = await handlers['gh:enqueuePRRefresh'](null, { + candidate, + reason: 'active', + priority: 80 + }) + + expect(first).toEqual({ kind: 'skipped', skippedReason: 'validation-denied' }) + expect(second).toEqual({ kind: 'skipped', skippedReason: 'validation-backoff' }) + expect(first).not.toBe(false) + expect(second).not.toBe(false) + expect(enqueuePRRefreshMock).not.toHaveBeenCalled() + }) + + it('uses registered repo routing fields for automatic PR refresh candidates', async () => { + repos = [ + { + id: 'repo-ssh', + path: '/workspace/remote-repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-real', + executionHostId: 'ssh:ssh-real' + } + ] + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:enqueuePRRefresh'](null, { + candidate: { + cacheKey: 'remote::feature/test', + repoPath: '/workspace/remote-repo', + repoId: 'repo-ssh', + branch: 'feature/test', + repoKind: 'git', + connectionId: 'ssh-stale', + executionHostId: 'runtime:stale', + connectionState: 'disconnected', + localGitOptions: { wslDistro: 'Stale' } + }, + reason: 'active', + priority: 80 + }) + + const candidate = enqueuePRRefreshMock.mock.calls[0]?.[0] + expect(candidate).toEqual( + expect.objectContaining({ + repoPath: '/workspace/remote-repo', + repoId: 'repo-ssh', + connectionId: 'ssh-real', + executionHostId: 'ssh:ssh-real', + connectionState: 'connected' + }) + ) + expect(candidate).not.toHaveProperty('localGitOptions') + }) + + it('keeps manual PR refresh validation strict', async () => { + registerGitHubHandlers(store as never, stats as never) + + await expect( + handlers['gh:refreshPRNow'](null, { + candidate: { + cacheKey: 'missing::feature/test', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/test', + repoKind: 'git' + } + }) + ).rejects.toThrow('Access denied: unknown repository path') + + expect(refreshPRNowMock).not.toHaveBeenCalled() + }) + + it('filters invalid visible PR refresh candidates while keeping valid candidates', async () => { + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:reportVisiblePRRefreshCandidates']( + { sender: { id: 7, once: vi.fn() } }, + { + generation: 1, + candidates: [ + { + cacheKey: 'valid::feature/test', + repoPath: '/workspace/repo', + repoId: 'repo-1', + branch: 'feature/test', + repoKind: 'git' + }, + { + cacheKey: 'missing::feature/old', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/old', + repoKind: 'git' + } + ] + } + ) + + expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith( + [ + expect.objectContaining({ + cacheKey: 'valid::feature/test', + repoPath: '/workspace/repo', + repoId: 'repo-1' + }) + ], + 1, + 7 + ) + }) + + it('clears a sender visible PR refresh set when all current candidates are invalid', async () => { + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:reportVisiblePRRefreshCandidates']( + { sender: { id: 8, once: vi.fn() } }, + { + generation: 2, + candidates: [ + { + cacheKey: 'missing::feature/old', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/old', + repoKind: 'git' + } + ] + } + ) + + expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith([], 2, 8) + }) + it('rejects GitHub source context from a different host', async () => { registerGitHubHandlers(store as never, stats as never) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index f4c4b1a6401..87b873e73af 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -11,6 +11,7 @@ import type { GitHubOwnerRepo, GitHubPullRequestStateUpdate, GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, GitHubPRRefreshReason, PRRefreshOutcome } from '../../shared/types' @@ -62,6 +63,10 @@ import { import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' import { getRateLimit } from '../github/rate-limit' import { diagnoseGhAuth } from '../github/auth-diagnose' +import { + notePRRefreshValidationDenial, + type PRRefreshValidationDenialReason +} from '../github/pr-refresh-validation-backoff' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { GitHubPRFile } from '../../shared/types' import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' @@ -140,27 +145,53 @@ type RepoScopedArgs = { sourceContext?: TaskSourceContext | null } -function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { +type RegisteredRepoValidationResult = + | { kind: 'ok'; repo: Repo } + | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } + +function validateRegisteredRepo( + args: string | RepoScopedArgs, + store: Store, + repos = store.getRepos() +): RegisteredRepoValidationResult { const repoPath = typeof args === 'string' ? args : args.repoPath const repoId = typeof args === 'string' ? undefined : args.repoId const resolvedRepoPath = resolve(repoPath) - const repo = store - .getRepos() - .find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) + const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) if (!repo) { - throw new Error('Access denied: unknown repository path') + return { + kind: 'denied', + reason: 'unknown-repo', + message: 'Access denied: unknown repository path' + } } if (repoId && resolve(repo.path) !== resolvedRepoPath) { - throw new Error('Access denied: repository path does not match repo id') + return { + kind: 'denied', + reason: 'repo-path-mismatch', + message: '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 { + kind: 'denied', + reason: 'host-mismatch', + message: 'Access denied: GitHub source host does not match repository host' + } } - return repo + return { kind: 'ok', repo } +} + +function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { + const result = validateRegisteredRepo(args, store) + if (result.kind === 'denied') { + throw new Error(result.message) + } + return result.repo } function repoConnectionId(repo: Repo): string | null { @@ -172,6 +203,50 @@ function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: strin return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] } +function applyRepoToPRRefreshCandidate( + store: Store, + repo: Repo, + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshCandidate { + const localGitOptions = localGitOptionArgs(store, repo)[0] + const appliedCandidate = { ...candidate } + delete appliedCandidate.localGitOptions + delete appliedCandidate.connectionId + delete appliedCandidate.executionHostId + delete appliedCandidate.connectionState + return { + ...appliedCandidate, + repoPath: repo.path, + repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), + connectionId: repoConnectionId(repo), + executionHostId: repo.executionHostId ?? null, + connectionState: repo.connectionId ? 'connected' : 'unknown' + } +} + +function validateAutomaticPRRefreshCandidate( + candidate: GitHubPRRefreshCandidate, + store: Store, + repos = store.getRepos() +): + | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } + | { + kind: 'skipped' + result: Extract + } { + const result = validateRegisteredRepo(candidate, store, repos) + if (result.kind === 'denied') { + const skippedReason = notePRRefreshValidationDenial({ + repoId: candidate.repoId, + repoPath: candidate.repoPath, + reason: result.reason + }) + return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } + } + return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) } +} + export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { @@ -246,16 +321,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:refreshPRNow', async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { - const repo = assertRegisteredRepo(args.candidate.repoPath, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - const outcome = await refreshPRNow({ - ...args.candidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repo.connectionId ?? args.candidate.connectionId, - connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState - }) + const repo = assertRegisteredRepo(args.candidate, store) + const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate)) recordPRIfNeeded(repo, outcome) return outcome } @@ -270,22 +337,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi reason: GitHubPRRefreshReason priority?: number } - ) => { - const repo = assertRegisteredRepo(args.candidate.repoPath, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - enqueuePRRefresh( - { - ...args.candidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repo.connectionId ?? args.candidate.connectionId, - connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState - }, - args.reason, - args.priority ?? 0 - ) - return true + ): GitHubPRRefreshEnqueueResult => { + const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) + if (validation.kind === 'skipped') { + return validation.result + } + enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0) + return { kind: 'queued' } } ) @@ -300,18 +358,14 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi clearVisiblePRRefreshWindow(senderId) }) } - const candidates = args.candidates.map((candidate) => { - const repo = assertRegisteredRepo(candidate.repoPath, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return { - ...candidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repo.connectionId ?? candidate.connectionId, - connectionState: repo.connectionId ? 'connected' : candidate.connectionState + const candidates: GitHubPRRefreshCandidate[] = [] + const repos = store.getRepos() + for (const candidate of args.candidates) { + const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) + if (validation.kind === 'ok') { + candidates.push(validation.candidate) } - }) + } reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) return true } diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 94b969848b2..d3ffef854ef 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -76,6 +76,7 @@ vi.mock('../gitea/client', () => ({ import { _resetPreflightCache, detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, registerPreflightHandlers, runPreflightCheck } from './preflight' @@ -105,6 +106,7 @@ describe('preflight', () => { handleMock.mockReset() execFileAsyncMock.mockReset() hydrateShellPathMock.mockReset() + hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' }) mergePathSegmentsMock.mockReset() getActiveMultiplexerMock.mockReset() getBitbucketAuthStatusMock.mockReset() @@ -575,6 +577,63 @@ describe('preflight', () => { await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor']) }) + it('hydrates shell PATH before user-facing agent detection', async () => { + const originalPath = process.env.PATH + process.env.PATH = '/usr/bin' + hydrateShellPathMock.mockResolvedValueOnce({ + segments: ['/home/test/.local/bin'], + ok: true, + failureReason: 'none' + }) + mergePathSegmentsMock.mockImplementationOnce((segments: string[]) => { + process.env.PATH = [...segments, '/usr/bin'].join(':') + return segments + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) { + return { stdout: '/home/test/.local/bin/codex\n' } + } + throw new Error('not found') + }) + + try { + await expect(detectInstalledAgentsWithShellPathHydration()).resolves.toEqual(['codex']) + } finally { + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + } + expect(hydrateShellPathMock).toHaveBeenCalledWith() + expect(mergePathSegmentsMock).toHaveBeenCalledWith(['/home/test/.local/bin']) + }) + + it('does not run host shell hydration for WSL agent detection', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[5]) + if (script.includes("'claude'")) { + return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + await expect( + detectInstalledAgentsWithShellPathHydration({ wslDistro: 'Ubuntu' }) + ).resolves.toEqual(['claude']) + expect(hydrateShellPathMock).not.toHaveBeenCalled() + }) + it('detects Mistral Vibe from the installed vibe executable', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 8d65e0acc4b..fbe00db4605 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -15,6 +15,7 @@ import { runPreflightCommandInWsl } from './preflight-wsl-command' import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' import { buildLocalPreflightEnv } from './preflight-local-env' import { getPreflightWslTarget, type PreflightRuntimeContext } from './preflight-runtime-target' +import { hydrateShellPathForAgentDetection } from './agent-detection-shell-path' const execFileAsync = promisify(execFile) const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 @@ -195,6 +196,13 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id)) } +export async function detectInstalledAgentsWithShellPathHydration( + context?: PreflightRuntimeContext +): Promise { + await hydrateShellPathForAgentDetection(context) + return detectInstalledAgents(context) +} + export type RefreshAgentsResult = { /** Agents detected after hydrating PATH from the user's login shell. */ agents: string[] @@ -352,11 +360,8 @@ export function registerPreflightHandlers(): void { } ) - ipcMain.handle( - 'preflight:detectAgents', - async (_event, args?: PreflightRuntimeContext): Promise => { - return detectInstalledAgents(args) - } + ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) => + detectInstalledAgentsWithShellPathHydration(args) ) ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => { diff --git a/src/main/ipc/worktree-change-invalidators.ts b/src/main/ipc/worktree-change-invalidators.ts new file mode 100644 index 00000000000..a43657743b4 --- /dev/null +++ b/src/main/ipc/worktree-change-invalidators.ts @@ -0,0 +1,25 @@ +const worktreeChangeInvalidators = new Set<(repoId: string) => void>() + +export function registerWorktreeChangeInvalidator( + invalidator: (repoId: string) => void +): () => void { + worktreeChangeInvalidators.add(invalidator) + return () => { + worktreeChangeInvalidators.delete(invalidator) + } +} + +export function runWorktreeChangeInvalidators(repoId: string): void { + const invalidators = Array.from(worktreeChangeInvalidators) + for (const invalidator of invalidators) { + try { + invalidator(repoId) + } catch (error) { + console.warn('[worktrees] worktree change invalidator failed:', error) + } + } +} + +export function __resetWorktreeChangeInvalidatorsForTests(): void { + worktreeChangeInvalidators.clear() +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 50fb87e58fb..74dfb7db9f8 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -57,6 +57,7 @@ import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { getSshGitUsername } from '../git/git-username' +import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -1328,6 +1329,9 @@ async function getRemoteLocalBaseRefUpdateSuggestionForWorktreeCreate( } export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { + // Why: invalidate detected-worktree caches before renderer observers react, + // so follow-up listDetected reads post-change state. + runWorktreeChangeInvalidators(repoId) if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('worktrees:changed', { repoId }) } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 3b7130d040d..0f043c7c6fe 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join, resolve } from 'path' -import type { CreateWorktreeResult } from '../../shared/types' +import type { CreateWorktreeResult, GitWorktreeInfo, Worktree } from '../../shared/types' const ORIGINAL_PLATFORM = process.platform @@ -226,9 +226,12 @@ vi.mock('./pty', () => ({ getLocalPtyProvider: getLocalPtyProviderMock })) -import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote' +import { + __resetSshWorktreeCreateFetchCacheForTests, + notifyWorktreesChanged +} from './worktree-remote' import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth' -import { registerWorktreeHandlers } from './worktrees' +import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' type HandlerMap = Record unknown> @@ -272,6 +275,7 @@ describe('registerWorktreeHandlers', () => { beforeEach(() => { setPlatform(ORIGINAL_PLATFORM) __resetSshWorktreeCreateFetchCacheForTests() + __resetDetectedWorktreeScanCacheForTests() invalidateAuthorizedRootsCache() for (const m of [ handleMock, @@ -2033,6 +2037,313 @@ describe('registerWorktreeHandlers', () => { }) }) + it('reuses a recent authoritative detected worktree scan', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + const first = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + const second = await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + + expect(first).toEqual(second) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('coalesces concurrent authoritative detected worktree scans', async () => { + listWorktreesMock.mockImplementation(async () => { + await Promise.resolve() + return [ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ] + }) + + await Promise.all([ + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }), + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }), + handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + ]) + + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('rechecks detected worktree metadata while reusing a cached raw scan', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + let currentMeta = makeWorktreeMeta({ isPinned: false }) + store.getWorktreeMeta.mockImplementation(() => currentMeta) + store.setWorktreeMeta.mockImplementation(() => currentMeta) + const first = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + currentMeta = makeWorktreeMeta({ isPinned: true }) + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(first.worktrees[0].isPinned).toBe(false) + expect(second.worktrees[0].isPinned).toBe(true) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + + it('rescans detected worktrees after the scan cache TTL expires', async () => { + vi.useFakeTimers() + try { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await vi.advanceTimersByTimeAsync(5_001) + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/new-worktree' + ]) + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('starts the detected scan cache TTL after a slow scan completes', async () => { + vi.useFakeTimers() + try { + listWorktreesMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]), + 6_000 + ) + }) + ) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + const first = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await vi.advanceTimersByTimeAsync(6_000) + await first + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees.map((worktree) => worktree.path)).toEqual(['/workspace/repo']) + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('invalidates the detected scan cache before worktree change notifications', async () => { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/new-worktree', + head: 'feature-head', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const second = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(second.worktrees).toHaveLength(2) + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + }) + + it('rescans detected worktrees after the local create flow notifies worktree changes', async () => { + listWorktreesMock + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/improve-dashboard', + head: 'feature-head', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + .mockResolvedValueOnce([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/workspace/improve-dashboard', + head: 'feature-head', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard' + }) + const detected = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + + expect(detected.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/improve-dashboard' + ]) + expect(listWorktreesMock).toHaveBeenCalledTimes(3) + }) + + it('does not run fresh-scan side effects from a detected scan invalidated while in flight', async () => { + let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {} + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void + }) + ) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-1::/workspace/new-worktree': { + worktreeId: 'repo-1::/workspace/new-worktree', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/workspace/repo', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { + source: 'manual-action', + confidence: 'explicit' + }, + createdAt: 0 + } + }) + + const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + resolveScan([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + await pendingList + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(listWorktreesMock).toHaveBeenCalledTimes(1) + }) + it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { const fetchRemoteTrackingRef = vi.fn(async () => {}) const exec = vi.fn(async (args: string[]) => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 564d369d914..f550d11079a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -77,6 +77,7 @@ import { cleanupUnusedWorktreePushTargetRemoteSsh, notifyWorktreesChanged } from './worktree-remote' +import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators' import { invalidateAuthorizedRootsCache, isENOENT, @@ -387,6 +388,83 @@ function getPreservedBranchCleanupTarget( const loggedUnavailableSshGitProviders = new Set() const loggedWorktreeListFailures = new Set() const loggedMalformedWorktreeMetaKeys = new Set() +// Why: absorb renderer polling bursts while keeping external worktree-change +// lag bounded to one short refresh window. +const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 + +type DetectedWorktreeScanCacheEntry = { + expiresAt: number + worktrees: GitWorktreeInfo[] +} + +type DetectedWorktreeScanResult = { + gitWorktrees: GitWorktreeInfo[] + fresh: boolean +} + +const detectedWorktreeScanCache = new Map() +const detectedWorktreeScanInFlight = new Map>() +const detectedWorktreeScanGenerations = new Map() + +function invalidateDetectedWorktreeScanCache(repoId: string): void { + detectedWorktreeScanCache.delete(repoId) + detectedWorktreeScanInFlight.delete(repoId) + detectedWorktreeScanGenerations.set( + repoId, + (detectedWorktreeScanGenerations.get(repoId) ?? 0) + 1 + ) +} + +registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) + +export function __resetDetectedWorktreeScanCacheForTests(): void { + detectedWorktreeScanCache.clear() + detectedWorktreeScanInFlight.clear() + detectedWorktreeScanGenerations.clear() +} + +async function listDetectedGitWorktrees( + store: Store, + repo: Repo +): Promise { + if (repo.connectionId || isFolderRepo(repo)) { + return { + gitWorktrees: await listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)), + fresh: true + } + } + + const cached = detectedWorktreeScanCache.get(repo.id) + if (cached && cached.expiresAt > Date.now()) { + return { gitWorktrees: cached.worktrees, fresh: false } + } + + const inFlight = detectedWorktreeScanInFlight.get(repo.id) + if (inFlight) { + return { gitWorktrees: await inFlight, fresh: false } + } + + const scan = listRepoWorktrees(repo, getLocalProjectWorktreeGitOptions(store, repo)) + const generation = detectedWorktreeScanGenerations.get(repo.id) ?? 0 + detectedWorktreeScanInFlight.set(repo.id, scan) + try { + const gitWorktrees = await scan + // Why: a create/remove notification can invalidate while the git scan is + // still running. Do not let that stale scan repopulate the cache afterward. + const isCurrentGeneration = (detectedWorktreeScanGenerations.get(repo.id) ?? 0) === generation + if (isCurrentGeneration) { + detectedWorktreeScanCache.set(repo.id, { + worktrees: gitWorktrees, + expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS + }) + } + return { gitWorktrees, fresh: isCurrentGeneration } + } finally { + if (detectedWorktreeScanInFlight.get(repo.id) === scan) { + detectedWorktreeScanInFlight.delete(repo.id) + } + } +} function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { if (keySet.has(key)) { @@ -946,6 +1024,7 @@ export function registerWorktreeHandlers( try { let gitWorktrees: GitWorktreeInfo[] + let freshScan = true if (isFolderRepo(repo)) { return { repoId: repo.id, @@ -966,13 +1045,14 @@ export function registerWorktreeHandlers( } gitWorktrees = await provider.listWorktrees(repo.path) } else { - gitWorktrees = await listRepoWorktrees( - repo, - getLocalProjectWorktreeGitOptions(store, repo) - ) + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) } - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return { repoId: repo.id, diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index befbbeed95c..49a49a46076 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -18,10 +18,10 @@ vi.mock('./client', () => ({ jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) })) -function makeEntry(): JiraClientForSite { +function makeEntry(id = 'site-1'): JiraClientForSite { return { site: { - id: 'site-1', + id, siteUrl: 'https://example.atlassian.net', email: 'ada@example.com', displayName: 'Example Jira', @@ -60,6 +60,71 @@ describe('Jira issue operations', () => { ).rejects.toThrow(error.message) }) + it('rejects single-site search failures so the UI can surface them', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1')]) + jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow('Forbidden') + }) + + it('includes Jira status codes in surfaced single-site search failures', async () => { + const error = Object.assign(new Error('Forbidden'), { status: 403 }) + getClientsMock.mockReturnValue([makeEntry('site-1')]) + jiraRequestMock.mockRejectedValueOnce(error) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow( + 'Error 403: Forbidden' + ) + }) + + it('keeps healthy sites when one site fails under an "all" search', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({ + issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }] + }) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'all')).resolves.toMatchObject([ + { key: 'BRV-1', title: 'Healthy' } + ]) + }) + + it('keeps healthy sites when the saved selection fans out without an explicit site', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden')).mockResolvedValueOnce({ + issues: [{ id: '1', key: 'BRV-1', fields: { summary: 'Healthy' } }] + }) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20)).resolves.toMatchObject([ + { key: 'BRV-1', title: 'Healthy' } + ]) + }) + + it('surfaces an error when every site fails under an "all" search', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + jiraRequestMock + .mockRejectedValueOnce(new Error('Forbidden')) + .mockRejectedValueOnce(new Error('Service Unavailable')) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Forbidden') + }) + + it('prefers operational failures when every "all" search site fails', async () => { + const authError = new Error('Unauthorized') + const operationalError = new Error('Service Unavailable') + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + isAuthErrorMock.mockImplementation((error) => error === authError) + jiraRequestMock.mockRejectedValueOnce(authError).mockRejectedValueOnce(operationalError) + const { searchIssues } = await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'all')).rejects.toThrow('Service Unavailable') + expect(clearTokenMock).toHaveBeenCalledWith('site-1') + }) + it('paginates Jira project search results before sorting them', async () => { jiraRequestMock .mockResolvedValueOnce({ diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 60a7dc49f8e..3bf8237349c 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -68,8 +68,38 @@ function clampLimit(limit: number | undefined, fallback = 30): number { return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) } -function shouldThrowAuthError(selection: JiraSiteSelection | null | undefined): boolean { - return selection !== 'all' +type JiraIssueSearchFailure = { + error: unknown + auth: boolean +} + +function getErrorStatus(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('status' in error)) { + return null + } + const status = (error as { status?: unknown }).status + return typeof status === 'number' && Number.isFinite(status) ? status : null +} + +function toIssueSearchFailureError(error: unknown): unknown { + const status = getErrorStatus(error) + if ( + status === null || + !(error instanceof Error) || + error.message.startsWith(`Error ${status}:`) + ) { + return error + } + return new Error(`Error ${status}: ${error.message}`) +} + +function shouldSurfaceSiteFailure( + selection: JiraSiteSelection | null | undefined, + entryCount: number +): boolean { + // getClients can resolve an omitted selection to the persisted 'all' choice; + // multi-entry reads need the same resilient fan-out policy as explicit 'all'. + return selection !== 'all' && entryCount <= 1 } function asRecord(value: unknown): JiraRecord { @@ -345,26 +375,37 @@ export async function searchIssues( return [] } const safeLimit = clampLimit(limit) + const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) + const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) const results = await Promise.all( - entries.map(async (entry) => { + entries.map(async (entry, index) => { await acquire() try { return await searchIssuesForClient(entry, jql.trim(), safeLimit) } catch (error) { - if (isAuthError(error)) { + const authFailure = isAuthError(error) + if (authFailure) { clearToken(entry.site.id) - if (shouldThrowAuthError(siteId)) { - throw error - } - } else { - console.warn('[jira] searchIssues failed:', error) } - return [] + if (surfaceSiteFailure) { + throw toIssueSearchFailureError(error) + } + console.warn('[jira] searchIssues failed:', error) + failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } + return [] as JiraIssue[] } finally { release() } }) ) + // 'all' fan-out: only surface an error when every connected site failed, so a + // partial success (or a genuinely empty result) is not reported as an error. + const recordedFailures = failures.filter( + (failure): failure is JiraIssueSearchFailure => failure !== undefined + ) + if (recordedFailures.length === entries.length) { + throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error + } return entries.length === 1 ? results.flat().slice(0, safeLimit) : sortAndLimitIssues(results.flat(), safeLimit) @@ -388,7 +429,7 @@ export async function getIssue( } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) - if (shouldThrowAuthError(siteId)) { + if (shouldSurfaceSiteFailure(siteId, entries.length)) { throw error } } else { @@ -590,7 +631,7 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } -function tableExists(db: Database.Database, tableName: string): boolean { - const row = db - .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") - .get(tableName) as { found?: number } | undefined - return row?.found === 1 -} - -function columnExists(db: Database.Database, tableName: string, columnName: string): boolean { - const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] - return rows.some((row) => row.name === columnName) -} - function getProjectJoin(db: Database.Database): string { return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') ? 'LEFT JOIN project p ON p.id = s.project_id' diff --git a/src/main/opencode-usage/schema-helpers.ts b/src/main/opencode-usage/schema-helpers.ts new file mode 100644 index 00000000000..d392eb58d7d --- /dev/null +++ b/src/main/opencode-usage/schema-helpers.ts @@ -0,0 +1,31 @@ +import type SyncDatabase from '../sqlite/sync-database' + +// Why: OpenCode's usage scanner and the AI Vault session scanner both need to +// probe the opencode.db schema shape across multiple DB generations. Centralizing +// the probes here avoids two private copies and keeps the contract testable. +type Database = SyncDatabase.Database + +/** + * Check whether a table exists in the given SQLite database. + * @param db - A readonly or read-write SyncDatabase instance. + * @param tableName - The table name to look up in sqlite_master. + * @returns `true` if the table exists, `false` otherwise. + */ +export function tableExists(db: Database, tableName: string): boolean { + const row = db + .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(tableName) as { found?: number } | undefined + return row?.found === 1 +} + +/** + * Check whether a column exists on a table in the given SQLite database. + * @param db - A readonly or read-write SyncDatabase instance. + * @param tableName - The table to inspect via PRAGMA table_info. + * @param columnName - The column name to find. + * @returns `true` if the column exists on the table, `false` otherwise. + */ +export function columnExists(db: Database, tableName: string, columnName: string): boolean { + const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as { name?: string }[] + return rows.some((row) => row.name === columnName) +} diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 2ebcf99053c..a6fd5911fa2 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -69,6 +69,22 @@ describe('SshGitProvider', () => { }) }) + it('getStatus forwards upstream-negative-cache bypass only when requested', async () => { + const statusResult = { entries: [], conflictOperation: 'unknown' } + mux.request.mockResolvedValue(statusResult) + + await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: true }) + await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: false }) + + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.status', { + worktreePath: '/home/user/repo', + bypassEffectiveUpstreamNegativeCache: true + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', { + worktreePath: '/home/user/repo' + }) + }) + it('checkIgnoredPaths sends git.checkIgnored request', async () => { mux.request.mockResolvedValue(['dist/bundle.js']) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index b9495dd7e7c..9c93c46e6d5 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -3,7 +3,7 @@ indirection — every method is a 1:1 forwarder to a relay RPC plus a small amount of param plumbing. */ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IGitProvider } from './types' +import type { GitProviderStatusOptions, IGitProvider } from './types' import type { GitStatusResult, GitDiffResult, @@ -82,12 +82,16 @@ export class SshGitProvider implements IGitProvider { async getStatus( worktreePath: string, - options?: { includeIgnored?: boolean } + options?: GitProviderStatusOptions ): Promise { const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {} return (await this.mux.request('git.status', { worktreePath, - ...includeIgnoredArgs + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs })) as GitStatusResult } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 1a11df9e1af..c8a090e59fa 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -165,8 +165,13 @@ export type IFilesystemProvider = { // ─── Git Provider ─────────────────────────────────────────────────── +export type GitProviderStatusOptions = { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean +} + export type IGitProvider = { - getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise + getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise getHistory(worktreePath: string, options?: GitHistoryOptions): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 0801f002ed5..e7e8770a89c 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -89,6 +89,17 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(command).not.toContain('`e]133') }) + it('normalizes MSYS drive cwd before spawning native PowerShell', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice' + ) + + expect(result.effectiveCwd).toBe('C:\\Users\\alice\\project') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => { const result = resolveWindowsShellLaunchArgs( 'powershell.exe', @@ -189,6 +200,33 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) + it('translates MSYS drive cwd to /mnt//... for wsl.exe', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice', + undefined, + 'codex' + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project')) + expect(result.effectiveCwd).toBe('C:\\Users\\alice') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + + it('does not treat MSYS drive cwd as a WSL POSIX cwd', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + '/c/Users/alice/project', + 'C:\\Users\\alice', + { distro: 'Ubuntu', treatPosixCwdAsWsl: true } + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/project', 'Ubuntu')) + expect(result.effectiveCwd).toBe('C:\\Users\\alice') + expect(result.validationCwd).toBe('C:\\Users\\alice\\project') + }) + it('escapes single quotes when translating a WSL cwd', () => { const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice') // The injected sh cmd must not break out of the surrounding single quotes diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 9944b127745..0e3d181fdd4 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -105,6 +105,19 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { return distro ? ['-d', distro, ...shellArgs] : shellArgs } +function normalizeMsysDrivePath(cwd: string): string { + const match = cwd.match(/^\/([A-Za-z])(?:\/(.*))?$/) + if (!match) { + return cwd + } + + // Why: Git Bash/MSYS launch contexts can hand Windows terminals `/d/repo`; + // ConPTY requires the equivalent native drive path as its cwd. + const driveLetter = match[1].toUpperCase() + const rest = match[2]?.replace(/\//g, '\\') ?? '' + return rest ? `${driveLetter}:\\${rest}` : `${driveLetter}:\\` +} + /** Build the argv + effective cwd for a Windows shell launch. * * - cmd.exe: `/K chcp 65001 > nul` so multi-byte CJK output renders correctly. @@ -122,6 +135,8 @@ export function resolveWindowsShellLaunchArgs( startupCommand?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() + const nativeCwd = normalizeMsysDrivePath(cwd) + const isMsysDriveCwd = nativeCwd !== cwd if (shellBasename === 'cmd.exe') { const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) @@ -133,8 +148,8 @@ export function resolveWindowsShellLaunchArgs( : CMD_UTF8_SETUP_COMMAND ], ...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}), - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } @@ -147,16 +162,16 @@ export function resolveWindowsShellLaunchArgs( ...(powerShellCommand.startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } if (isWindowsGitBashShellPath(shellPath)) { return { shellArgs: ['--login', '-i'], - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } @@ -169,25 +184,25 @@ export function resolveWindowsShellLaunchArgs( validationCwd: cwd } } - if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/')) { + if (wslContext?.treatPosixCwdAsWsl && cwd.startsWith('/') && !isMsysDriveCwd) { return { shellArgs: buildWslShellArgs(cwd, wslContext.distro), effectiveCwd: defaultCwd, validationCwd: toWindowsWslPath(cwd, wslContext.distro) } } - const driveMatch = cwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/) - const linuxCwd = driveMatch ? toLinuxPath(cwd) : '/mnt/c' + const driveMatch = nativeCwd.replace(/\\/g, '/').match(/^([A-Za-z]):\/?(.*)$/) + const linuxCwd = driveMatch ? toLinuxPath(nativeCwd) : '/mnt/c' return { shellArgs: buildWslShellArgs(linuxCwd, wslContext?.distro), effectiveCwd: defaultCwd, - validationCwd: cwd + validationCwd: nativeCwd } } return { shellArgs: [], - effectiveCwd: cwd, - validationCwd: cwd + effectiveCwd: nativeCwd, + validationCwd: nativeCwd } } diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index b3c5dc54c20..5c984c607e8 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -169,7 +169,7 @@ describe('fetchClaudeRateLimits', () => { ) }) - it('uses legacy Keychain credentials for host system default without an explicit config dir', async () => { + it('falls back to legacy Keychain credentials for host system default without an explicit config dir', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { configDir, @@ -178,22 +178,16 @@ describe('fetchClaudeRateLimits', () => { stripAuthEnv: false, provenance: 'system' } - vi.mocked(readActiveClaudeKeychainCredentials).mockResolvedValueOnce( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'legacy-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) - vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValue( - JSON.stringify({ - claudeAiOauth: { - accessToken: 'stale-scoped-oauth-token', - expiresAt: Date.now() + 60_000 - } - }) - ) + vi.mocked(readActiveClaudeKeychainCredentialsStrict) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'legacy-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ provider: 'claude', @@ -202,8 +196,9 @@ describe('fetchClaudeRateLimits', () => { weekly: { usedPercent: 34 } }) - expect(readActiveClaudeKeychainCredentials).toHaveBeenCalledWith(undefined) - expect(readActiveClaudeKeychainCredentialsStrict).not.toHaveBeenCalled() + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(1, configDir) + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenNthCalledWith(2, undefined) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() expect(netFetchMock).toHaveBeenCalledWith( 'https://api.anthropic.com/api/oauth/usage', expect.objectContaining({ @@ -214,6 +209,46 @@ describe('fetchClaudeRateLimits', () => { ) }) + it('reads scoped Keychain credentials for host system default without an explicit config dir', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + runtime: 'host', + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'scoped-oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowPtyFallback: false }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + session: { usedPercent: 12 }, + weekly: { usedPercent: 34 } + }) + + expect(readActiveClaudeKeychainCredentialsStrict).toHaveBeenCalledWith(configDir) + expect(readActiveClaudeKeychainCredentials).not.toHaveBeenCalled() + expect(fetchViaPty).not.toHaveBeenCalled() + expect(netFetchMock).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer scoped-oauth-token' + }) + }) + ) + }) + it('falls back to the credentials file when Keychain access fails', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 2c8db5194cf..94f725c2366 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -249,13 +249,11 @@ function resolveOAuthCredentialReadOptions( if (!authPreparation) { return undefined } + // Why: Claude Code 2.1+ can scope even the default config dir's macOS + // Keychain item. Try scoped first, with legacy still handled as fallback. const readOptions: OAuthCredentialReadOptions = { - credentialsFileConfigDir: authPreparation.configDir - } - // Why: host system-default launches do not inject CLAUDE_CONFIG_DIR, so - // their Keychain lookup must mirror Claude's legacy service ordering. - if (authPreparation.envPatch.CLAUDE_CONFIG_DIR) { - readOptions.keychainConfigDir = authPreparation.configDir + credentialsFileConfigDir: authPreparation.configDir, + keychainConfigDir: authPreparation.configDir } return readOptions } diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index dc48755b1e5..76bcc8672d6 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -353,7 +353,7 @@ describe('RateLimitService', () => { expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ authPreparation: undefined, - allowPtyFallback: true + allowPtyFallback: false }) expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1) @@ -464,6 +464,67 @@ describe('RateLimitService', () => { expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' }) }) + it('does not use Claude PTY fallback for system-default usage refreshes', async () => { + const service = new RateLimitService() + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '/tmp/.claude', + runtime: 'host', + wslDistro: null, + wslLinuxConfigDir: null, + envPatch: {}, + stripAuthEnv: false, + provenance: 'system' + })) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ provenance: 'system' }), + allowPtyFallback: false + }) + }) + + it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => { + const service = new RateLimitService() + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: undefined, + allowPtyFallback: false + }) + }) + + it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => { + const service = new RateLimitService() + service.setClaudeFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + service.setClaudeAuthPreparationResolver(async () => ({ + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.claude', + runtime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxConfigDir: '/home/jin/.claude', + envPatch: {}, + stripAuthEnv: true, + provenance: 'wsl:Ubuntu:system' + })) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 20, Date.now())) + + await service.refresh() + + expect(fetchClaudeRateLimits).toHaveBeenCalledWith({ + authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }), + allowPtyFallback: false + }) + }) + it('does not cache host Codex usage under an outgoing WSL account', async () => { const service = new RateLimitService() const wslCodexHome = diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 6b6e3c064db..822dfa37101 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -72,6 +72,18 @@ function normalizePollingInterval(ms: number): number { return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) } +function isSystemDefaultClaudeAuth( + authPreparation: ClaudeRuntimeAuthPreparation | undefined +): boolean { + // Why: fetch cycles classify missing Claude auth as system-default; keep the + // PTY fallback gate aligned so background refresh cannot trigger auth flows. + if (!authPreparation) { + return true + } + const provenance = authPreparation?.provenance + return provenance === 'system' || Boolean(provenance?.endsWith(':system')) +} + export class RateLimitService { private state: InternalRateLimitState = { claude: null, @@ -792,10 +804,18 @@ export class RateLimitService { return process.platform !== 'win32' } - private shouldAllowClaudePtyFallback(): boolean { + private shouldAllowClaudePtyFallback( + authPreparation: ClaudeRuntimeAuthPreparation | undefined + ): boolean { // Why: automatic recovery uses Claude CLI as the next source, but Windows // hidden PTY support remains less reliable than host/WSL shells. - return process.platform !== 'win32' + if (process.platform === 'win32') { + return false + } + // Why: system-default Claude is not an Orca-managed account. Background + // quota refresh may read existing OAuth, but must not launch Claude and + // trigger auth/browser flows for users who never configured Claude in Orca. + return !isSystemDefaultClaudeAuth(authPreparation) } private withFetchingStatus( @@ -861,7 +881,7 @@ export class RateLimitService { await Promise.allSettled([ fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback() + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) }), missingWslCodexHome ?? fetchCodexRateLimits({ @@ -1036,7 +1056,7 @@ export class RateLimitService { const claude = await fetchClaudeRateLimits({ authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback() + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation) }).catch( (err): ProviderRateLimits => ({ provider: 'claude', diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index c2cd775ac0e..e39ef000eea 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -23,6 +23,7 @@ import { type ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' +import type { GitProviderStatusOptions } from '../providers/types' import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' import { abortMerge, @@ -164,7 +165,7 @@ export class RuntimeGitCommands { async getRuntimeGitStatus( worktreeSelector: string, - options?: { includeIgnored?: boolean } + options?: GitProviderStatusOptions ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 096c3ecf771..25dfa727a77 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -164,7 +164,7 @@ const { addGitHubIssueCommentMock, listGitHubLabelsMock, listGitHubAssignableUsersMock, - detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, listGitLabMergeRequestsMock, listGitLabWorkItemsMock, @@ -255,7 +255,7 @@ const { addGitHubIssueCommentMock: vi.fn(), listGitHubLabelsMock: vi.fn(), listGitHubAssignableUsersMock: vi.fn(), - detectInstalledAgentsMock: vi.fn(), + detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), listGitLabMergeRequestsMock: vi.fn(), listGitLabWorkItemsMock: vi.fn(), @@ -319,7 +319,7 @@ vi.mock('../ipc/ssh', () => ({ })) vi.mock('../ipc/preflight', () => ({ - detectInstalledAgents: detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock })) @@ -620,8 +620,8 @@ function resetRuntimeTestMocks(): void { listGitHubLabelsMock.mockResolvedValue([]) listGitHubAssignableUsersMock.mockReset() listGitHubAssignableUsersMock.mockResolvedValue([]) - detectInstalledAgentsMock.mockReset() - detectInstalledAgentsMock.mockResolvedValue([]) + detectInstalledAgentsWithShellPathHydrationMock.mockReset() + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) detectRemoteAgentsMock.mockReset() detectRemoteAgentsMock.mockResolvedValue([]) listGitLabMergeRequestsMock.mockReset() @@ -17898,7 +17898,7 @@ describe('OrcaRuntimeService', () => { }) it('uses desktop task agent selection and bracketed-pastes startup drafts for local worktrees', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} const runtimeStore = { ...store, @@ -17959,7 +17959,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -18215,7 +18215,7 @@ describe('OrcaRuntimeService', () => { }) it('records the resolved fallback agent when the requested startup draft agent is disabled', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude']) const metaById: Record = {} const runtimeStore = { ...store, @@ -18276,7 +18276,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/tmp/workspaces/runtime-fallback-draft', @@ -18405,7 +18405,7 @@ describe('OrcaRuntimeService', () => { }) it('lets explicit startup draft agents override the desktop default', async () => { - detectInstalledAgentsMock.mockResolvedValue([]) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue([]) const metaById: Record = {} const runtimeStore = { ...store, @@ -18467,7 +18467,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -18485,7 +18485,7 @@ describe('OrcaRuntimeService', () => { }) it('does not auto-launch an agent for startup drafts when the default is blank', async () => { - detectInstalledAgentsMock.mockResolvedValue(['claude', 'codex']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValue(['claude', 'codex']) const metaById: Record = {} const runtimeStore = { ...store, @@ -18545,7 +18545,7 @@ describe('OrcaRuntimeService', () => { activate: true }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(detectRemoteAgentsMock).not.toHaveBeenCalled() expect(spawn).not.toHaveBeenCalled() expect(metaById[result.worktree.id]?.createdWithAgent).toBeUndefined() @@ -18638,7 +18638,7 @@ describe('OrcaRuntimeService', () => { }) expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' }) - expect(detectInstalledAgentsMock).not.toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-startup-draft', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a0ca7efcadf..bd81e59fcc7 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -172,7 +172,7 @@ import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' -import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight' +import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight' import { markCodexProjectTrusted, markCopilotFolderTrusted, @@ -11436,9 +11436,10 @@ export class OrcaRuntimeService { if (!agent) { let detected: string[] = [] try { + // Why: startup-draft fallback can run from sparse runtime launch envs too. detected = repo.connectionId ? await detectRemoteAgents({ connectionId: repo.connectionId }) - : await detectInstalledAgents() + : await detectInstalledAgentsWithShellPathHydration() } catch { detected = [] } diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index ca80c969709..c94c3349c6c 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -8,7 +8,8 @@ export const WorktreeSelector = z.object({ }) export const GitStatusParams = WorktreeSelector.extend({ - includeIgnored: z.boolean().optional() + includeIgnored: z.boolean().optional(), + bypassEffectiveUpstreamNegativeCache: z.boolean().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index b5937d8fc61..c6307c0aa63 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -55,6 +55,32 @@ describe('git RPC methods', () => { }) }) + it('forwards upstream-negative-cache bypass for status requests', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.status', { + worktree: 'id:wt-1', + bypassEffectiveUpstreamNegativeCache: true + }) + ) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + bypassEffectiveUpstreamNegativeCache: true + }) + expect(response).toMatchObject({ + ok: true, + result: { entries: [] } + }) + }) + it('returns ignored paths for selected explorer rows', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 06cc3205452..964f7d09ce3 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -87,10 +87,23 @@ export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.status', params: GitStatusParams, - handler: async (params, { runtime }) => - params.includeIgnored === undefined + handler: async (params, { runtime }) => { + const options = + params.includeIgnored === undefined && + params.bypassEffectiveUpstreamNegativeCache === undefined + ? undefined + : { + ...(params.includeIgnored === undefined + ? {} + : { includeIgnored: params.includeIgnored }), + ...(params.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}) + } + return options === undefined ? runtime.getRuntimeGitStatus(params.worktree) - : runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored }) + : runtime.getRuntimeGitStatus(params.worktree, options) + } }), defineMethod({ name: 'git.checkIgnored', diff --git a/src/main/runtime/rpc/methods/preflight.test.ts b/src/main/runtime/rpc/methods/preflight.test.ts index 51a1231caa3..d28b97b8e53 100644 --- a/src/main/runtime/rpc/methods/preflight.test.ts +++ b/src/main/runtime/rpc/methods/preflight.test.ts @@ -5,19 +5,19 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import { PREFLIGHT_METHODS } from './preflight' const { - detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgentsMock, refreshShellPathAndDetectAgentsMock, runPreflightCheckMock } = vi.hoisted(() => ({ - detectInstalledAgentsMock: vi.fn(), + detectInstalledAgentsWithShellPathHydrationMock: vi.fn(), detectRemoteAgentsMock: vi.fn(), refreshShellPathAndDetectAgentsMock: vi.fn(), runPreflightCheckMock: vi.fn() })) vi.mock('../../../ipc/preflight', () => ({ - detectInstalledAgents: detectInstalledAgentsMock, + detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock, detectRemoteAgents: detectRemoteAgentsMock, refreshShellPathAndDetectAgents: refreshShellPathAndDetectAgentsMock, runPreflightCheck: runPreflightCheckMock @@ -46,7 +46,7 @@ describe('preflight RPC methods', () => { }) it('detects agents and refreshes PATH on the server through runtime RPC', async () => { - detectInstalledAgentsMock.mockResolvedValueOnce(['codex']) + detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['codex']) refreshShellPathAndDetectAgentsMock.mockResolvedValueOnce({ agents: ['codex', 'claude'], addedPathSegments: ['/opt/bin'], @@ -60,7 +60,7 @@ describe('preflight RPC methods', () => { const detected = await dispatcher.dispatch(makeRequest('preflight.detectAgents')) const refreshed = await dispatcher.dispatch(makeRequest('preflight.refreshAgents')) - expect(detectInstalledAgentsMock).toHaveBeenCalled() + expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenCalled() expect(refreshShellPathAndDetectAgentsMock).toHaveBeenCalled() expect(detected).toMatchObject({ ok: true, result: ['codex'] }) expect(refreshed).toMatchObject({ diff --git a/src/main/runtime/rpc/methods/preflight.ts b/src/main/runtime/rpc/methods/preflight.ts index 720a823cc22..4e0f06201e9 100644 --- a/src/main/runtime/rpc/methods/preflight.ts +++ b/src/main/runtime/rpc/methods/preflight.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { detectRemoteAgents, - detectInstalledAgents, + detectInstalledAgentsWithShellPathHydration, refreshShellPathAndDetectAgents, runPreflightCheck } from '../../../ipc/preflight' @@ -23,7 +23,7 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [ defineMethod({ name: 'preflight.detectAgents', params: null, - handler: async () => detectInstalledAgents() + handler: async () => detectInstalledAgentsWithShellPathHydration() }), defineMethod({ name: 'preflight.detectRemoteAgents', diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 1091ef71417..f69ca694c4a 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -17,7 +17,8 @@ const { registerPtyHandlersMock, hydrateLocalPtyRegistryAtBootMock, setupAutoUpdaterMock, - browserManagerUnregisterAllMock + browserManagerUnregisterAllMock, + runWorktreeChangeInvalidatorsMock } = vi.hoisted(() => ({ onMock: vi.fn(), removeAllListenersMock: vi.fn(), @@ -33,7 +34,8 @@ const { registerPtyHandlersMock: vi.fn(), hydrateLocalPtyRegistryAtBootMock: vi.fn(), setupAutoUpdaterMock: vi.fn(), - browserManagerUnregisterAllMock: vi.fn() + browserManagerUnregisterAllMock: vi.fn(), + runWorktreeChangeInvalidatorsMock: vi.fn() })) vi.mock('electron', () => ({ @@ -64,6 +66,10 @@ vi.mock('../ipc/worktrees', () => ({ registerWorktreeHandlers: registerWorktreeHandlersMock })) +vi.mock('../ipc/worktree-change-invalidators', () => ({ + runWorktreeChangeInvalidators: runWorktreeChangeInvalidatorsMock +})) + vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: vi.fn(), registerPtyHandlers: registerPtyHandlersMock @@ -592,5 +598,9 @@ describe('attachMainWindowServices', () => { } ] ]) + expect(runWorktreeChangeInvalidatorsMock).toHaveBeenCalledWith('repo-1') + expect(runWorktreeChangeInvalidatorsMock.mock.invocationCallOrder[0]).toBeLessThan( + sendMock.mock.invocationCallOrder[0] + ) }) }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index ce720ca612c..eace514bcc9 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -36,6 +36,7 @@ import { isNativeFileDropPayload, type NativeFileDropPayload } from '../../share import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' +import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' let appReloadHandlerTokenCounter = 0 let activeAppReloadHandlerToken: number | null = null @@ -216,8 +217,12 @@ function registerRuntimeWindowLifecycle( } } runtime.setNotifier({ - worktreesChanged: (repoId, renamed) => - send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }), + worktreesChanged: (repoId, renamed) => { + // Why: clear detected-worktree scan caches before renderer listeners + // handle this event, preventing stale TTL reads after mutations. + runWorktreeChangeInvalidators(repoId) + send('worktrees:changed', renamed ? { repoId, renamed } : { repoId }) + }, worktreeBaseStatus: (event) => send('worktree:baseStatus', event), worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), reposChanged: () => send('repos:changed'), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e57c86aa041..55c32a9a5d9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -115,6 +115,7 @@ import type { FloatingTerminalCwdRequest, GitHubIssueUpdate, GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, GitHubPRRefreshEvent, GitHubPRRefreshReason, GetRateLimitResult, @@ -1156,7 +1157,7 @@ export type PreloadApi = { candidate: GitHubPRRefreshCandidate reason: GitHubPRRefreshReason priority?: number - }) => Promise + }) => Promise reportVisiblePRRefreshCandidates: (args: { candidates: GitHubPRRefreshCandidate[] generation: number @@ -2107,6 +2108,7 @@ export type PreloadApi = { worktreePath: string connectionId?: string includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean }) => Promise checkIgnored: (args: { worktreePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 7852bd91f31..7936edb853b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2536,6 +2536,7 @@ const api = { worktreePath: string connectionId?: string includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean }): Promise => ipcRenderer.invoke('git:status', args), checkIgnored: (args: { worktreePath: string diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index 47ddb8a671d..5c409f4c943 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -5,6 +5,7 @@ import * as path from 'path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitExec } from './git-handler-ops' import { getStatusOp } from './git-handler-status-ops' +import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' const LARGE_STATUS_ENTRY_COUNT = 150_000 @@ -16,14 +17,21 @@ function buildLargeStatusOutput(count: number): string { return lines.join('\n') } +function buildBranchStatusOutput(head: string, branch: string): string { + return [`# branch.oid ${head}`, `# branch.head ${branch}`].join('\n') +} + describe('getStatusOp', () => { let tmpDir: string beforeEach(() => { + clearNoEffectiveUpstreamStatusCache() tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-status-')) }) afterEach(async () => { + vi.useRealTimers() + clearNoEffectiveUpstreamStatusCache() await fs.rm(tmpDir, { recursive: true, force: true }) }) @@ -70,4 +78,170 @@ describe('getStatusOp', () => { expect(result.didHitLimit).toBeUndefined() expect(result.entries).toHaveLength(5) }) + + it('caches no-effective-upstream probes across status polls for the same head', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + const first = await getStatusOp(git, { worktreePath: tmpDir }) + const firstCallCount = git.mock.calls.length + const second = await getStatusOp(git, { worktreePath: tmpDir }) + + expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(second.upstreamStatus).toEqual(first.upstreamStatus) + expect(git.mock.calls).toHaveLength(firstCallCount + 1) + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(1) + expect( + git.mock.calls.filter( + ([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature') + ) + ).toHaveLength(1) + }) + + it('keeps no-effective-upstream probes cached beyond thirty seconds', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + vi.setSystemTime(31_000) + await getStatusOp(git, { worktreePath: tmpDir }) + + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(1) + }) + + it('coalesces concurrent no-effective-upstream probes', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + await Promise.resolve() + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + await Promise.resolve() + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await Promise.all([ + getStatusOp(git, { worktreePath: tmpDir }), + getStatusOp(git, { worktreePath: tmpDir }), + getStatusOp(git, { worktreePath: tmpDir }) + ]) + + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(1) + expect( + git.mock.calls.filter( + ([args]) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature') + ) + ).toHaveLength(1) + }) + + it('invalidates cached no-effective-upstream probes when the branch changes', async () => { + let branch = 'feature' + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', branch), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: `${branch}\n`, stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + branch = 'other-feature' + await getStatusOp(git, { worktreePath: tmpDir }) + + expect( + git.mock.calls + .filter( + ([args]) => + args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/')) + ) + .map(([args]) => args.at(-1)) + ).toEqual(['refs/remotes/origin/feature', 'refs/remotes/origin/other-feature']) + }) + + it('does not cache a configured push target signal', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature/fix'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature/fix\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature/fix') + } + if (args[0] === 'config' && args.includes('branch.feature/fix.pushRemote')) { + return { stdout: 'fork\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('remote.pushDefault')) { + throw new Error('missing push default') + } + if (args[0] === 'config' && args.includes('branch.feature/fix.remote')) { + return { stdout: 'fork\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.feature/fix.merge')) { + return { stdout: 'refs/heads/feature/fix\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.feature/fix.base')) { + throw new Error('missing branch base') + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/'))) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + await getStatusOp(git, { worktreePath: tmpDir }) + + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(2) + }) }) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index d0c8842f502..03fe7c35b69 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -11,10 +11,8 @@ import { parseUnmergedEntry } from './git-handler-utils' import { parseStatusOutput } from './git-status-output-parser' import type { GitExec } from './git-handler-ops' import type { GitUpstreamStatus } from '../shared/types' -import { - getEffectiveGitUpstreamStatus, - splitRemoteBranchName -} from '../shared/git-effective-upstream' +import { splitRemoteBranchName } from '../shared/git-effective-upstream' +import { readOrProbeNoEffectiveUpstreamStatus } from './git-status-upstream-negative-cache' import { applyLineStats, collectUntrackedAdditions, @@ -133,11 +131,20 @@ export async function getStatusOp( if (!didHitLimit) { if (shouldProbeEffectiveUpstreamStatus(branch, upstreamStatus?.upstreamName)) { - try { - upstreamStatus = await getEffectiveGitUpstreamStatus((args) => git(args, worktreePath)) - } catch { - // Why: status polling should keep returning working-tree entries even - // if the richer upstream probe hits a transient SSH/git ref error. + const branchName = getShortBranchName(branch) + if (branchName) { + try { + upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName }, + (args) => git(args, worktreePath), + { + bypassCache: params.bypassEffectiveUpstreamNegativeCache === true + } + ) + } catch { + // Why: status polling should keep returning working-tree entries even + // if the richer upstream probe hits a transient SSH/git ref error. + } } } diff --git a/src/relay/git-status-upstream-negative-cache.test.ts b/src/relay/git-status-upstream-negative-cache.test.ts new file mode 100644 index 00000000000..e232f791265 --- /dev/null +++ b/src/relay/git-status-upstream-negative-cache.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearNoEffectiveUpstreamStatusCache, + clearNoEffectiveUpstreamStatusCacheEntry, + getNoEffectiveUpstreamStatusCacheCountForTests, + getNoEffectiveUpstreamStatusGenerationCountForTests, + readOrProbeNoEffectiveUpstreamStatus +} from './git-status-upstream-negative-cache' + +describe('relay upstream negative cache', () => { + beforeEach(() => { + clearNoEffectiveUpstreamStatusCache() + }) + + afterEach(() => { + clearNoEffectiveUpstreamStatusCache() + }) + + it('bypasses cached no-effective-upstream status when requested', async () => { + let originBranchExists = false + const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + throw new Error('missing remote branch') + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const first = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + originBranchExists = true + const automatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { + bypassCache: true + }) + + expect(first).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(automatic).toEqual(first) + expect(strict).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + }) + + it('keeps an older automatic negative probe from overwriting a strict positive result', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const runGit = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { + bypassCache: true + }) + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + const staleAutomatic = await automatic + const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + + expect(strict).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(staleAutomatic).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(nextAutomatic).toEqual(strict) + }) + + it('does not trim generation for an unresolved automatic probe', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { + bypassCache: true + }) + for (let index = 0; index < 512; index += 1) { + const branchName = `other-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }, + { bypassCache: true } + ) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + + expect(strict).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(nextAutomatic).toEqual(strict) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('does not trim generation for a cleared automatic probe before it settles', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + clearNoEffectiveUpstreamStatusCacheEntry(identity) + for (let index = 0; index < 512; index += 1) { + const branchName = `other-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }, + { bypassCache: true } + ) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + + expect(nextAutomatic).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds no-effective-upstream entries', async () => { + for (let index = 0; index < 513; index += 1) { + const branchName = `feature-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + } + ) + } + + expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(512) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds write-generation entries from positive strict probes', async () => { + for (let index = 0; index < 513; index += 1) { + const branchName = `feature-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }, + { bypassCache: true } + ) + } + + expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(0) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) + }) +}) diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts new file mode 100644 index 00000000000..a9dbec3175c --- /dev/null +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -0,0 +1,183 @@ +import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' +import type { GitCommandRunner } from '../shared/git-effective-upstream' +import type { GitUpstreamStatus } from '../shared/types' + +const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000 +const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512 + +type NoEffectiveUpstreamCacheIdentity = { + worktreePath: string + branchName: string + upstreamName?: string +} + +type NoEffectiveUpstreamCacheEntry = { + status: GitUpstreamStatus + expiresAt: number +} + +const noEffectiveUpstreamByIdentity = new Map() +const noEffectiveUpstreamInFlight = new Map>() +const retiredNoEffectiveUpstreamInFlight = new Map>() +const noEffectiveUpstreamWriteGeneration = new Map() + +function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity): string { + return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0') +} + +function readCachedNoEffectiveUpstreamStatus( + cacheKey: string, + nowMs = Date.now() +): GitUpstreamStatus | null { + const entry = noEffectiveUpstreamByIdentity.get(cacheKey) + if (!entry) { + return null + } + if (entry.expiresAt <= nowMs) { + noEffectiveUpstreamByIdentity.delete(cacheKey) + return null + } + return entry.status +} + +function hasPendingNoEffectiveUpstreamProbe(cacheKey: string): boolean { + return ( + noEffectiveUpstreamInFlight.has(cacheKey) || retiredNoEffectiveUpstreamInFlight.has(cacheKey) + ) +} + +function trimNoEffectiveUpstreamWriteGeneration(): void { + for (const cacheKey of noEffectiveUpstreamWriteGeneration.keys()) { + if (noEffectiveUpstreamWriteGeneration.size <= MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) { + break + } + if (hasPendingNoEffectiveUpstreamProbe(cacheKey)) { + continue + } + noEffectiveUpstreamWriteGeneration.delete(cacheKey) + } +} + +function cacheNoEffectiveUpstreamStatus( + cacheKey: string, + status: GitUpstreamStatus, + probedSameNameOriginRef: boolean, + writeGeneration: number, + nowMs = Date.now() +): void { + // Why: hasConfiguredPushTarget controls publish behavior; keep that signal + // fresh rather than serving a stale positive from status polling. + if (status.hasUpstream || status.hasConfiguredPushTarget) { + noEffectiveUpstreamByIdentity.delete(cacheKey) + noEffectiveUpstreamWriteGeneration.set(cacheKey, writeGeneration + 1) + trimNoEffectiveUpstreamWriteGeneration() + return + } + if ((noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { + return + } + // Why: only cache negatives after probing origin/; other resolution + // paths can fail without proving the same-name publish branch is absent. + if (!probedSameNameOriginRef) { + return + } + noEffectiveUpstreamByIdentity.set(cacheKey, { + status, + expiresAt: nowMs + NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS + }) + while (noEffectiveUpstreamByIdentity.size > MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES) { + const oldest = noEffectiveUpstreamByIdentity.keys().next() + if (oldest.done) { + break + } + noEffectiveUpstreamByIdentity.delete(oldest.value) + noEffectiveUpstreamWriteGeneration.delete(oldest.value) + } + trimNoEffectiveUpstreamWriteGeneration() +} + +export async function readOrProbeNoEffectiveUpstreamStatus( + identity: NoEffectiveUpstreamCacheIdentity, + runGit: GitCommandRunner, + options: { bypassCache?: boolean } = {} +): Promise { + const cacheKey = noEffectiveUpstreamCacheKey(identity) + if (options.bypassCache !== true) { + const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey) + if (cachedStatus) { + return cachedStatus + } + + const inFlight = noEffectiveUpstreamInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } + } + + let probedSameNameOriginRef = false + const writeGeneration = noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0 + const probe = getEffectiveGitUpstreamStatus((args) => { + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${identity.branchName}`)) { + probedSameNameOriginRef = true + } + return runGit(args) + }).then((status) => { + cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef, writeGeneration) + return status + }) + if (options.bypassCache !== true) { + noEffectiveUpstreamInFlight.set(cacheKey, probe) + } + try { + return await probe + } finally { + if (noEffectiveUpstreamInFlight.get(cacheKey) === probe) { + noEffectiveUpstreamInFlight.delete(cacheKey) + trimNoEffectiveUpstreamWriteGeneration() + } + } +} + +export function clearNoEffectiveUpstreamStatusCache(): void { + noEffectiveUpstreamByIdentity.clear() + noEffectiveUpstreamInFlight.clear() + retiredNoEffectiveUpstreamInFlight.clear() + noEffectiveUpstreamWriteGeneration.clear() +} + +export function clearNoEffectiveUpstreamStatusCacheEntry( + identity: NoEffectiveUpstreamCacheIdentity +): void { + const cacheKey = noEffectiveUpstreamCacheKey(identity) + retireNoEffectiveUpstreamProbe(cacheKey) + noEffectiveUpstreamByIdentity.delete(cacheKey) + noEffectiveUpstreamInFlight.delete(cacheKey) + noEffectiveUpstreamWriteGeneration.set( + cacheKey, + (noEffectiveUpstreamWriteGeneration.get(cacheKey) ?? 0) + 1 + ) +} + +function retireNoEffectiveUpstreamProbe(cacheKey: string): void { + const retiredProbe = noEffectiveUpstreamInFlight.get(cacheKey) + if (!retiredProbe) { + return + } + retiredNoEffectiveUpstreamInFlight.set(cacheKey, retiredProbe) + void retiredProbe + .finally(() => { + if (retiredNoEffectiveUpstreamInFlight.get(cacheKey) === retiredProbe) { + retiredNoEffectiveUpstreamInFlight.delete(cacheKey) + trimNoEffectiveUpstreamWriteGeneration() + } + }) + .catch(() => undefined) +} + +export function getNoEffectiveUpstreamStatusCacheCountForTests(): number { + return noEffectiveUpstreamByIdentity.size +} + +export function getNoEffectiveUpstreamStatusGenerationCountForTests(): number { + return noEffectiveUpstreamWriteGeneration.size +} diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index b2dfffe8ed2..6d2f9339b9e 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -1,5 +1,49 @@ -import { describe, expect, it } from 'vitest' -import { buildCommandLookupSpec, hasAbsoluteCommandPath } from './preflight-handler' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileAsyncMock } = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn() +})) + +vi.mock('child_process', () => { + const execFileWithPromisify = Object.assign(vi.fn(), { + [Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock + }) + return { execFile: execFileWithPromisify } +}) + +import { + buildCommandLookupSpec, + buildCommandLookupSpecs, + hasAbsoluteCommandPath, + isCommandOnPathForRelay +} from './preflight-handler' + +function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { + return [ + mode, + [ + `if resolved=$(command -v ${command} 2>/dev/null); then`, + 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', + 'fi' + ].join('\n') + ] +} + +function fishLookupArgs(command: string): string[] { + return [ + '-ilc', + [ + `set -l resolved (command -v ${command} 2>/dev/null)`, + 'if test -n "$resolved"', + 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', + 'end' + ].join('\n') + ] +} + +beforeEach(() => { + execFileAsyncMock.mockReset() +}) describe('buildCommandLookupSpec', () => { it('uses where.exe on native Windows SSH hosts', () => { @@ -10,15 +54,160 @@ describe('buildCommandLookupSpec', () => { }) }) - it('passes the command as an argument to the POSIX login-shell probe', () => { - expect(buildCommandLookupSpec('codex', 'linux')).toEqual({ + it('falls back to sh for POSIX probes without a configured shell', () => { + expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({ file: '/bin/sh', - args: ['-lc', 'command -v "$1"', 'sh', 'codex'] + args: lookupArgs("'codex'") + }) + }) + + it('uses the configured remote shell for POSIX probes', () => { + expect(buildCommandLookupSpec('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual({ + file: '/bin/zsh', + args: lookupArgs("'codex'", '-ilc') + }) + }) + + it('quotes command names in shell probes', () => { + expect( + buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash') + ).toEqual({ + file: '/bin/bash', + args: lookupArgs("'agent'\\''cli'", '-ilc') + }) + }) +}) + +describe('buildCommandLookupSpecs', () => { + it('falls back to inherited PATH after a trusted configured POSIX shell', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual([ + { file: '/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, + { file: '/bin/sh', args: lookupArgs("'codex'") } + ]) + }) + + it('allows a custom shell path only when the account login shell matches', () => { + expect( + buildCommandLookupSpecs( + 'codex', + 'darwin', + { SHELL: '/opt/homebrew/bin/zsh' }, + '/opt/homebrew/bin/zsh' + ) + ).toEqual([ + { file: '/opt/homebrew/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, + { file: '/bin/sh', args: lookupArgs("'codex'") } + ]) + }) + + it('allows conservative system shell paths when account lookup is unavailable', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/bash' }, null)[0]).toEqual({ + file: '/usr/bin/bash', + args: lookupArgs("'codex'", '-ilc') + }) + }) + + it('uses fish syntax for trusted fish shells', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/fish' }, null)[0]).toEqual({ + file: '/usr/bin/fish', + args: fishLookupArgs("'codex'") + }) + }) + + it('ignores untrusted temp shell paths even when the basename is supported', () => { + expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/tmp/zsh' }, '/bin/bash')).toEqual([ + { file: '/bin/sh', args: lookupArgs("'codex'") } + ]) + }) + + it('ignores untrusted home-bin shell paths even when the basename is supported', () => { + expect( + buildCommandLookupSpecs('codex', 'linux', { SHELL: '/home/test/bin/bash' }, '/bin/bash') + ).toEqual([{ file: '/bin/sh', args: lookupArgs("'codex'") }]) + }) +}) + +describe('isCommandOnPathForRelay', () => { + it('falls back to inherited PATH when shell startup returns no absolute command path', async () => { + execFileAsyncMock + .mockResolvedValueOnce({ stdout: 'welcome\ncodex is a function\n' }) + .mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/bin/zsh', PATH: '/usr/bin' }, + accountLoginShell: '/bin/zsh' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenNthCalledWith( + 1, + '/bin/zsh', + lookupArgs("'codex'", '-ilc'), + { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + } + ) + expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs("'codex'"), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + }) + }) + + it('falls back to inherited PATH when shell startup fails', async () => { + execFileAsyncMock + .mockRejectedValueOnce(new Error('startup failed')) + .mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/bin/bash', PATH: '/usr/bin' }, + accountLoginShell: '/bin/bash' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not execute an untrusted configured shell before inherited PATH lookup', async () => { + execFileAsyncMock.mockResolvedValueOnce({ stdout: '__ORCA_AGENT_PATH__/relay/path/codex\n' }) + + await expect( + isCommandOnPathForRelay('codex', { + platform: 'linux', + env: { SHELL: '/tmp/zsh', PATH: '/usr/bin' }, + accountLoginShell: '/bin/bash' + }) + ).resolves.toBe(true) + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs("'codex'"), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/tmp/zsh' }), + timeout: 5000 }) }) }) describe('hasAbsoluteCommandPath', () => { + it('ignores banners and shell function output', () => { + expect(hasAbsoluteCommandPath('/tmp/not-the-agent\ncodex is a shell function\n', 'linux')).toBe( + false + ) + }) + + it('ignores unmarked POSIX absolute paths from shell startup output', () => { + expect(hasAbsoluteCommandPath('/tmp/not-the-agent\n', 'linux')).toBe(false) + }) + + it('recognizes a sentinel-marked command path amid shell startup and exit output', () => { + expect( + hasAbsoluteCommandPath('welcome\n__ORCA_AGENT_PATH__/opt/bin/codex\nlogout-banner\n', 'linux') + ).toBe(true) + }) + it('recognizes Windows absolute command paths', () => { expect( hasAbsoluteCommandPath('C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd\r\n', 'win32') diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index fb47248323d..a0382d77275 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -1,4 +1,5 @@ import { execFile } from 'child_process' +import { userInfo } from 'os' import { promisify } from 'util' import path, { win32 } from 'path' import type { RelayDispatcher } from './dispatcher' @@ -12,6 +13,16 @@ type CommandLookupSpec = { windowsHide?: true } +type RelayCommandLookupOptions = { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + accountLoginShell?: string | null +} + +const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish']) +const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) +const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__' + export class PreflightHandler { private dispatcher: RelayDispatcher @@ -43,35 +54,77 @@ export class PreflightHandler { return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] } } - // Why: SSH exec channels give the relay a minimal environment without - // .zprofile/.bash_profile sourced. Running `which` directly would miss - // agents installed via Homebrew, nvm, cargo, pipx, etc. Spawning a login - // shell (`-lc`) ensures PATH matches what the user's PTY sessions see. - // Windows has no /bin/sh on native OpenSSH hosts, so use where.exe there. + // Why: SSH exec channels give the relay a minimal environment without shell + // startup files sourced. Ask the user's configured shell so agent dirs added + // by zsh/bash/fish startup hooks match the remote terminal experience. + // Windows has no POSIX shell on native OpenSSH hosts, so use where.exe there. private async isCommandOnPath(command: string): Promise { - try { - const spec = buildCommandLookupSpec(command, process.platform) - const { stdout } = await execFileAsync(spec.file, spec.args, { - encoding: 'utf-8', - env: buildRelayCommandEnv(), - timeout: 5000, - ...(spec.windowsHide ? { windowsHide: true } : {}) - }) - return hasAbsoluteCommandPath(stdout, process.platform) - } catch { - return false - } + return isCommandOnPathForRelay(command) } } export function buildCommandLookupSpec( command: string, - platform: NodeJS.Platform + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + accountLoginShell?: string | null ): CommandLookupSpec { + const [spec] = buildCommandLookupSpecs(command, platform, env, accountLoginShell) + return spec ?? buildPosixCommandLookupSpec(command, '/bin/sh') +} + +export function buildCommandLookupSpecs( + command: string, + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv = process.env, + accountLoginShell?: string | null +): CommandLookupSpec[] { if (platform === 'win32') { - return { file: 'where.exe', args: [command], windowsHide: true } + return [{ file: 'where.exe', args: [command], windowsHide: true }] } - return { file: '/bin/sh', args: ['-lc', 'command -v "$1"', 'sh', command] } + const trustedShell = pickTrustedPosixShell( + env, + resolveAccountLoginShell(platform, accountLoginShell) + ) + const specs: CommandLookupSpec[] = [] + + if (trustedShell) { + specs.push(buildPosixCommandLookupSpec(command, trustedShell)) + } + + const inheritedPathSpec = buildPosixCommandLookupSpec(command, '/bin/sh') + if (!trustedShell || trustedShell !== inheritedPathSpec.file) { + specs.push(inheritedPathSpec) + } + + return specs +} + +export async function isCommandOnPathForRelay( + command: string, + options: RelayCommandLookupOptions = {} +): Promise { + const platform = options.platform ?? process.platform + const env = options.env ?? process.env + const specs = buildCommandLookupSpecs(command, platform, env, options.accountLoginShell) + + for (const spec of specs) { + try { + const { stdout } = await execFileAsync(spec.file, spec.args, { + encoding: 'utf-8', + env: buildRelayCommandEnv(env, platform), + timeout: 5000, + ...(spec.windowsHide ? { windowsHide: true } : {}) + }) + if (hasAbsoluteCommandPath(stdout, platform)) { + return true + } + } catch { + // Try the inherited-PATH fallback before reporting the agent missing. + } + } + + return false } export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean { @@ -79,5 +132,86 @@ export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform return output .split(/\r?\n/) .map((line) => line.trim()) - .some((line) => pathOps.isAbsolute(line)) + .some((line) => { + const resolvedPath = + platform === 'win32' + ? line + : line.startsWith(AGENT_PATH_PREFIX) + ? line.slice(AGENT_PATH_PREFIX.length) + : '' + return pathOps.isAbsolute(resolvedPath) + }) +} + +function buildPosixCommandLookupSpec(command: string, shell: string): CommandLookupSpec { + const shellName = path.posix.basename(shell).toLowerCase() + if (shellName === 'fish') { + return { file: shell, args: ['-ilc', buildFishCommandLookupScript(command)] } + } + return { file: shell, args: [getShellCommandMode(shell), buildShCommandLookupScript(command)] } +} + +function buildShCommandLookupScript(command: string): string { + const quotedCommand = shellQuote(command) + return [ + `if resolved=$(command -v ${quotedCommand} 2>/dev/null); then`, + `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, + 'fi' + ].join('\n') +} + +function buildFishCommandLookupScript(command: string): string { + const quotedCommand = shellQuote(command) + return [ + `set -l resolved (command -v ${quotedCommand} 2>/dev/null)`, + 'if test -n "$resolved"', + `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, + 'end' + ].join('\n') +} + +function resolveAccountLoginShell( + platform: NodeJS.Platform, + accountLoginShell?: string | null +): string | null { + if (accountLoginShell !== undefined) { + return accountLoginShell + } + if (platform === 'win32') { + return null + } + try { + return userInfo().shell ?? null + } catch { + return null + } +} + +function pickTrustedPosixShell( + env: NodeJS.ProcessEnv, + accountLoginShell: string | null +): string | null { + const shell = env.SHELL + if (!shell || !path.posix.isAbsolute(shell)) { + return null + } + const shellName = path.posix.basename(shell).toLowerCase() + if (!SUPPORTED_POSIX_SHELLS.has(shellName)) { + return null + } + if (accountLoginShell) { + return shell === accountLoginShell ? shell : null + } + return CONSERVATIVE_SYSTEM_SHELL_DIRS.has(path.posix.dirname(shell)) ? shell : null +} + +function getShellCommandMode(shell: string): '-lc' | '-ilc' { + const shellName = path.posix.basename(shell).toLowerCase() + // Why: bash/zsh/fish users commonly add package-manager bins from interactive + // startup files. POSIX sh/dash may not support interactive login flags. + return shellName === 'sh' || shellName === 'dash' ? '-lc' : '-ilc' +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index aacb0a5c9e5..1071ff9ae8d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -61,7 +61,10 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' +import { + TOGGLE_FLOATING_TERMINAL_EVENT, + requestFloatingTerminalOpenMaximized +} from '@/lib/floating-terminal' import { isFloatingWorkspacePanelFocused, isFloatingWorkspacePanelShortcut, @@ -1395,6 +1398,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1409,6 +1413,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1426,6 +1431,7 @@ function App(): React.JSX.Element { activeView, activeWorktreeId, actions, + floatingTerminalEnabled, floatingTerminalOpen, floatingVisibleTabCount, keybindings, @@ -1521,6 +1527,22 @@ function App(): React.JSX.Element { return } + // Why: when the floating workspace is closed, its own keydown handler is + // unmounted and cannot claim Cmd+Opt+Shift+A. Honor the maximize chord + // here by opening the panel with a one-shot intent so it mounts straight + // into the maximized state. While the panel is open, this is a no-op: the + // panel's handler owns the maximize/restore toggle. + if ( + !floatingTerminalOpen && + matchShortcut('floatingWorkspace.maximize') && + floatingTerminalEnabled + ) { + input.preventDefault() + requestFloatingTerminalOpenMaximized() + setFloatingTerminalOpenWithFocus(true) + return + } + // Why: keep this guard. TipTap's Cmd+B bold binding depends on the // window-level handler *not* toggling the sidebar when focus lives in an // editable surface. The main-process before-input-event already carves out @@ -1582,6 +1604,21 @@ function App(): React.JSX.Element { return } + // Toggle the "show sleeping workspaces" sidebar filter without opening the + // filters menu (issue #5209). When revealing them, open the left sidebar + // so the now-visible sleeping worktrees are actually reachable. + if (matchShortcut('sidebar.sleepingWorkspaces.toggle')) { + input.preventDefault() + notifyTerminalCapture('sidebar.sleepingWorkspaces.toggle') + const store = useAppStore.getState() + const nextShowSleeping = !store.showSleepingWorkspaces + store.setShowSleepingWorkspaces(nextShowSleeping) + if (nextShowSleeping) { + store.setSidebarOpen(true) + } + return + } + // Why: rename the active terminal tab. Cmd+R is free in the app/terminal // focus zone because the browser pane owns its own Cmd+R reload and that // focus never reaches this renderer-window handler. Only terminal tabs diff --git a/src/renderer/src/assets/mobile-page.css b/src/renderer/src/assets/mobile-page.css index 24ab4c3425b..df32f64ccae 100644 --- a/src/renderer/src/assets/mobile-page.css +++ b/src/renderer/src/assets/mobile-page.css @@ -557,6 +557,15 @@ height: 116px; } +.mobile-page-root .mp-qr-large { + padding: 10px; +} + +.mobile-page-root .mp-qr-large img { + width: 164px; + height: 164px; +} + .mobile-page-root .mp-qr-stack { display: flex; flex-direction: column; @@ -704,8 +713,9 @@ } .mobile-page-root .mp-network-select { - min-width: 220px; - max-width: 280px; + flex: 1 1 180px; + min-width: 0; + max-width: 240px; } .mobile-page-root .mp-network-refresh { diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c35ddee07af..a417419855e 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -17,10 +17,12 @@ import { ArrowDown, ArrowRight, ArrowUp, + Ban, Braces, Check, ChevronDown, ChevronLeft, + ChevronRight, CircleDashed, CircleDot, Copy, @@ -38,10 +40,10 @@ import { Pencil, Plus, RefreshCw, + Search, Send, Settings, UndoDot, - Users, Wrench, X } from 'lucide-react' @@ -59,7 +61,7 @@ import { } from '@/components/ui/accordion' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { DropdownMenu, DropdownMenuContent, @@ -159,6 +161,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' +import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, @@ -193,6 +196,13 @@ import { import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction +} from '@/components/task-page-github-status-actions' // 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. @@ -354,7 +364,9 @@ function getStateTone(item: GitHubWorkItem): string { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + // Why: closed issues can mean completed/resolved; keep them neutral instead + // of using destructive red, which is reserved for PR closed-without-merge. + return 'border-ring/50 bg-primary/10 text-foreground' } return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } @@ -386,25 +398,15 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - if (avatarUrl) { - return ( - - ) - } return ( - - {login.slice(0, 1).toUpperCase()} - + className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover" + /> ) } @@ -447,6 +449,239 @@ function buildRequestedReviewUsers( return Array.from(byLogin.values()) } +function PRAssigneesPanel({ + item, + repoPath, + projectOrigin, + sourceContext, + onMutated +}: { + item: GitHubWorkItem + repoPath: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + sourceContext?: TaskSourceContext | null + onMutated: () => void +}): React.JSX.Element { + const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) + const [localAssignees, setLocalAssignees] = useState( + () => item.assignees ?? [] + ) + const [assigneesSource, setAssigneesSource] = useState(() => ({ + itemId: item.id, + repoId: item.repoId, + assignees: item.assignees + })) + 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: PR assignees can change through background refetches; sync them + // before paint so the right rail never shows a stale reviewer/assignee split. + if ( + assigneesSource.itemId !== item.id || + assigneesSource.repoId !== item.repoId || + assigneesSource.assignees !== item.assignees + ) { + setAssigneesSource({ itemId: item.id, repoId: item.repoId, assignees: item.assignees }) + setLocalAssignees(item.assignees ?? []) + } + + const patchProjectRowIfNeeded = useCallback( + (assignees: string[]) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { assignees }) + }, + [patchProjectRowContent, projectOrigin] + ) + const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees]) + const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null + const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null + const repoAssigneesBySlug = useRepoAssigneesBySlug( + slugOwner, + slugRepo, + assigneeLogins, + sourceSettings + ) + const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) + const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath + const canEditAssignees = Boolean(projectOrigin || repoPath) + const assigneesByLogin = useMemo( + () => new Map(repoAssignees.data.map((user) => [user.login.toLowerCase(), user])), + [repoAssignees.data] + ) + + const handleAssigneeToggle = useCallback( + (login: string) => { + const lowerLogin = login.toLowerCase() + const isAssigned = localAssignees.some((user) => user.login.toLowerCase() === lowerLogin) + const prevAssignees = localAssignees + const candidate = assigneesByLogin.get(lowerLogin) ?? { login, name: null, avatarUrl: '' } + const nextAssignees = isAssigned + ? prevAssignees.filter((user) => user.login.toLowerCase() !== lowerLogin) + : [...prevAssignees, candidate] + const nextLogins = nextAssignees.map((user) => user.login) + const prevLogins = prevAssignees.map((user) => user.login) + + run('assignees', { + mutate: () => + runIssueUpdate({ + repoId: item.repoId, + repoPath, + sourceContext, + projectOrigin, + number: item.number, + updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } + }), + onOptimistic: () => { + setLocalAssignees(nextAssignees) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(nextLogins) + }, + onRevert: () => { + setLocalAssignees(prevAssignees) + patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(prevLogins) + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + onMutated() + }, + onError: (err) => toast.error(err) + }) + }, + [ + assigneesByLogin, + item.id, + item.number, + item.repoId, + localAssignees, + onMutated, + patchProjectRowIfNeeded, + patchWorkItem, + projectOrigin, + repoPath, + run, + sourceContext + ] + ) + + const checkIcon = ( + + + + ) + + return ( +
+
+ {translate('auto.components.GitHubItemDialog.83ac703dda', 'Assignees')} + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => { + const selected = localAssignees.some( + (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() + ) + return ( + + ) + })} +
+ )} +
+
+
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.c67de9e2fe', 'No one assigned')} +
+ ) : ( + + )} +
+ ) +} + function PRReviewersPanel({ item, loading, @@ -462,8 +697,6 @@ function PRReviewersPanel({ }): React.JSX.Element { const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') - const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') - const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 @@ -663,26 +896,6 @@ function PRReviewersPanel({ const canRequestReview = !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' - const measureReviewerPickerPlacement = useCallback(() => { - const rect = reviewerInputRef.current?.getBoundingClientRect() - if (!rect) { - setReviewerPickerSide('bottom') - setReviewerPickerMaxHeight(null) - return - } - - const gap = 8 - const minUsefulHeight = 180 - const availableBelow = window.innerHeight - rect.bottom - gap - const availableAbove = rect.top - gap - const nextSide = - availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom' - const available = nextSide === 'top' ? availableAbove : availableBelow - - setReviewerPickerSide(nextSide) - setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available))) - }, []) - const handleRequestReview = async (requestedLogins?: string[]): Promise => { if (submitting) { return @@ -856,9 +1069,6 @@ function PRReviewersPanel({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - if (nextOpen) { - measureReviewerPickerPlacement() - } setOpen(nextOpen) if (nextOpen) { scheduleReviewerInputFocus() @@ -936,165 +1146,80 @@ function PRReviewersPanel({ } return ( - + {loading && !hasReviewerMetadata ? ( +
+ + {translate('auto.components.GitHubItemDialog.6a45771d47', 'Loading reviewers')} +
+ ) : reviewers.length > 0 ? ( +
+ {reviewers.map((reviewer) => { + const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + return ( +
+ +
+
+ {reviewer.login} +
+ {reviewer.name ? ( +
+ {reviewer.name} +
+ ) : null} +
+ + {reviewer.stateLabel} + + {canRemoveReviewer ? ( + + + + + + {translate('auto.components.GitHubItemDialog.5c1c973855', 'Remove reviewer')} + + + ) : null} +
+ ) + })} +
+ ) : ( +
+ {translate('auto.components.GitHubItemDialog.36f9ac4a47', 'No reviewers requested.')} +
+ )} + ) } @@ -2794,7 +2979,7 @@ function ConversationTab({ const rightPanel = item.type === 'pr' ? ( -
+
+ (null) const [localAssignees, setLocalAssignees] = useState(assignees) const editedAssigneesItemKeyRef = useRef(null) const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) const repoOwnerSettings = useAppStore( useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) ) @@ -4723,6 +4940,33 @@ function GHEditSection({ const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath const hasAttachedWorkspace = attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined + const filteredDuplicateCandidates = useMemo( + () => + getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), + [duplicateIssueCandidates, duplicateSearch, item.number] + ) + const directDuplicateTarget = useMemo(() => { + const trimmed = duplicateSearch.trim() + const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) + if (!trimmed || !validation.ok) { + return null + } + if ( + filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) + ) { + return null + } + return validation.duplicateOf + }, [duplicateSearch, filteredDuplicateCandidates, item.number]) + const duplicatePickerTitle = useMemo(() => { + if (projectOrigin) { + return `${projectOrigin.owner}/${projectOrigin.repo}` + } + const parsed = parseOwnerRepoFromItemUrl(item.url) + return parsed + ? `${parsed.owner}/${parsed.repo}` + : translate('auto.components.TaskPage.repository', 'Repository') + }, [item.url, projectOrigin]) const handleOpenOrUseWorkspace = useCallback((): void => { if (onOpenOrUse) { onOpenOrUse(item) @@ -4742,7 +4986,7 @@ function GHEditSection({ }, [assigneesItemKey, assignees]) const handleStateChange = useCallback( - (newState: 'open' | 'closed') => { + (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { if (newState === localState) { return } @@ -4755,7 +4999,10 @@ function GHEditSection({ sourceContext, projectOrigin, number: item.number, - updates: { state: newState } + updates: + newState === 'closed' && closeAction + ? buildTaskPageGitHubCloseUpdate(closeAction) + : { state: newState } }), onOptimistic: () => { onStateChange(newState) @@ -4792,6 +5039,42 @@ function GHEditSection({ ] ) + const closeAsDuplicate = useCallback( + (targetIssueNumber: number | string) => { + const validation = validateTaskPageGitHubDuplicateTarget( + String(targetIssueNumber), + item.number + ) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + setDuplicateError(null) + handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) + setStatusPopoverOpen(false) + setDuplicatePickerOpen(false) + }, + [handleStateChange, item.number] + ) + + const handleDuplicateSearchSubmit = useCallback(() => { + const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + closeAsDuplicate(validation.duplicateOf) + }, [closeAsDuplicate, duplicateSearch, item.number]) + + const handleStatusPopoverOpenChange = useCallback((nextOpen: boolean) => { + setStatusPopoverOpen(nextOpen) + if (!nextOpen) { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + } + }, []) + const handleLabelToggle = useCallback( (label: string) => { const isAdding = !localLabels.includes(label) @@ -4947,6 +5230,196 @@ function GHEditSection({ ] ) + const renderIssueStatusPopover = (variant: 'sidebar' | 'pill'): React.JSX.Element => { + const isSidebar = variant === 'sidebar' + return ( + + + + + + {duplicatePickerOpen ? ( +
+
+ + + {duplicatePickerTitle} + +
+
+ + { + setDuplicateSearch(event.target.value) + setDuplicateError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

+ ) : null} +
+ {directDuplicateTarget ? ( + + ) : null} + {filteredDuplicateCandidates.map((candidate) => ( + + ))} + {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( +

+ {translate( + 'auto.components.TaskPage.noMatchingIssuesLoaded', + 'No matching issues loaded.' + )} +

+ ) : null} +
+
+ ) : ( + <> + + + + + + )} +
+
+ ) + } + if (item.type === 'pr') { return null } @@ -4971,51 +5444,7 @@ function GHEditSection({
{translate('auto.components.GitHubItemDialog.00ccdf9b5a', 'Status')}
- - - - - - - - - + {renderIssueStatusPopover('sidebar')} {/* Assignees */} @@ -5264,44 +5693,7 @@ function GHEditSection({ return (
{/* State */} - - - - - - - - - + {renderIssueStatusPopover('pill')} {/* Labels */} @@ -5571,29 +5963,37 @@ function GHCommentComposer({ const canSubmitComment = hasBoundedCommentBodyText(body) return ( -
+
void handleSubmit()} /> - + + + + + + {translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')} + +
) } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 0079a9aa868..6c1f8c1d9bf 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -95,6 +95,10 @@ type NewWorkspaceComposerCardProps = { canReuseSelectedBranch: boolean reuseSelectedBranch: boolean onReuseSelectedBranchChange: (next: boolean) => void + /** Shows the footer "Create more" switch — worktree targets only. */ + showCreateMultiple?: boolean + createMultiple?: boolean + onCreateMultipleChange?: (next: boolean) => void smartNameGitHubSourceContext?: TaskSourceContext | null /** Advisory shown under the name field when a fork PR can't accept maintainer pushes. */ forkPushWarning: string | null @@ -331,6 +335,9 @@ export default function NewWorkspaceComposerCard({ canReuseSelectedBranch, reuseSelectedBranch, onReuseSelectedBranchChange, + showCreateMultiple = false, + createMultiple = false, + onCreateMultipleChange, smartNameGitHubSourceContext, forkPushWarning, detectedAgentIds, @@ -810,7 +817,10 @@ export default function NewWorkspaceComposerCard({ />
-
+ {/* Why: Advanced is a disclosure header, so keep it visually grouped + with the content or footer below it while preserving normal spacing + from the Agent field above. */} +
+ ) : null} + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => { + const selected = localAssignees.some( + (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() + ) + return ( + + ) + })} +
+ )} +
+ +
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.PullRequestPage.1ff5d979df', 'No one assigned')} +
+ ) : ( +
    + {localAssignees.map((assignee) => ( +
  • + + + {assignee.login} + +
  • + ))} +
+ )} + + ) +} + function PRReviewersPanel({ item, loading, @@ -522,8 +728,6 @@ function PRReviewersPanel({ }): React.JSX.Element { const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') - const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') - const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 @@ -713,26 +917,6 @@ function PRReviewersPanel({ const canRequestReview = !!repoPath || getActiveRuntimeTarget(repoOwnerSettings).kind === 'environment' - const measureReviewerPickerPlacement = useCallback(() => { - const rect = reviewerInputRef.current?.getBoundingClientRect() - if (!rect) { - setReviewerPickerSide('bottom') - setReviewerPickerMaxHeight(null) - return - } - - const gap = 8 - const minUsefulHeight = 180 - const availableBelow = window.innerHeight - rect.bottom - gap - const availableAbove = rect.top - gap - const nextSide = - availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom' - const available = nextSide === 'top' ? availableAbove : availableBelow - - setReviewerPickerSide(nextSide) - setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available))) - }, []) - const handleRequestReview = async (requestedLogins?: string[]): Promise => { if (submitting) { return @@ -898,9 +1082,6 @@ function PRReviewersPanel({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - if (nextOpen) { - measureReviewerPickerPlacement() - } setOpen(nextOpen) if (nextOpen) { scheduleReviewerInputFocus() @@ -978,162 +1159,80 @@ function PRReviewersPanel({ } return ( - + {loading && !hasReviewerMetadata ? ( +
+ + {translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')} +
+ ) : reviewers.length > 0 ? ( +
+ {reviewers.map((reviewer) => { + const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + return ( +
+ +
+
+ {reviewer.login} +
+ {reviewer.name ? ( +
+ {reviewer.name} +
+ ) : null} +
+ + {reviewer.stateLabel} + + {canRemoveReviewer ? ( + + + + + + {translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')} + + + ) : null} +
+ ) + })} +
+ ) : ( +
+ {translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')} +
+ )} + ) } @@ -2968,7 +3127,7 @@ function ConversationTab({ const rightPanel = item.type === 'pr' ? ( -
+
+ +
- + + + + + + {translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')} + +
) } diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 53ffdffbc1d..fc56f6692c1 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -9,6 +9,7 @@ import { AlertCircle, ArrowDownUp, ArrowRight, + Ban, Check, CheckCircle2, ChevronDown, @@ -16,12 +17,14 @@ import { ChevronRight, CircleDot, Clock3, + Copy, EllipsisVertical, ExternalLink, Eye, Files, GitMerge, GitPullRequest, + GitPullRequestDraft, List, LoaderCircle, Lock, @@ -85,6 +88,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import TaskProjectSourceCombobox from '@/components/task-project-source-combobox' import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog' import { LinearScopeSelector } from '@/components/linear-scope-selector' @@ -104,6 +108,7 @@ import type { import { useConfirmationDialog } from '@/components/confirmation-dialog' import { getGitHubPRPrimaryReviewer, + getGitHubPRReviewerRows, getGitHubPRReviewLabel, normalizeGitHubReviewerLogins, parseGitHubReviewerInputLogins, @@ -209,6 +214,22 @@ import { resolveTaskPageGitHubStatusStateDraft, updateTaskPageGitHubStatusLocalState } from '@/components/task-page-github-status-state' +import { TaskPageGitHubWorkItemStateBadge } from '@/components/task-page-github-work-item-status-badge' +import { + getTaskPageGitHubPRIconTone, + isTaskPageGitHubDraftPR +} from '@/components/task-page-github-work-item-status' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction +} from '@/components/task-page-github-status-actions' +import { + createTaskPageJiraLoadFailureState, + type TaskPageJiraLoadError +} from '@/components/task-page-jira-load-state' import { deriveTaskPagePRCheckSummary } from '@/components/task-page-pr-check-summary' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { buildJiraCreateTextAdf } from '@/components/jira-create-adf' @@ -220,6 +241,7 @@ import type { GitHubOwnerRepo, GitHubAssignableUser, GitHubPRMergeMethod, + GitHubIssueUpdate, GitHubWorkItem, GitLabTodo, GitLabWorkItem, @@ -815,6 +837,51 @@ function groupLinearIssues( return [...sections.values()] } +function TaskPageJiraErrorBanner({ + error, + open, + onOpenChange +}: { + error: TaskPageJiraLoadError + open: boolean + onOpenChange: (open: boolean) => void +}): React.JSX.Element { + return ( + +
+ +
+
{error.title}
+ {error.details ? ( + <> + + + + +
+ {error.details} +
+
+ + ) : null} +
+
+
+ ) +} + function getLinearIssueGridTemplate(visibleProperties: ReadonlySet): string { const columns = ['96px', 'minmax(240px,1.55fr)'] if (visibleProperties.has('labels')) { @@ -965,6 +1032,34 @@ function GHStatusCell({ sourceContext?: TaskSourceContext | null }): React.JSX.Element { const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const [statusStateDraft, setStatusStateDraft] = useState(() => + createTaskPageGitHubStatusStateDraft(item) + ) + const [open, setOpen] = useState(false) + const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false) + const [duplicateSearch, setDuplicateSearch] = useState('') + const [duplicateError, setDuplicateError] = useState(null) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) const repoOwnerSettings = useAppStore( useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) ) @@ -978,11 +1073,29 @@ function GHStatusCell({ : repoOwnerSettings, [repoOwnerSettings, sourceContext] ) - const [statusStateDraft, setStatusStateDraft] = useState(() => - createTaskPageGitHubStatusStateDraft(item) - ) - const [open, setOpen] = useState(false) const reqRef = useRef(0) + const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url]) + const filteredDuplicateCandidates = useMemo( + () => + getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), + [duplicateIssueCandidates, duplicateSearch, item.number] + ) + const directDuplicateTarget = useMemo(() => { + const trimmed = duplicateSearch.trim() + const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) + if (!trimmed || !validation.ok) { + return null + } + if ( + filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) + ) { + return null + } + return validation.duplicateOf + }, [duplicateSearch, filteredDuplicateCandidates, item.number]) + const duplicatePickerTitle = parsedIssueLink?.slug + ? `${parsedIssueLink.slug.owner}/${parsedIssueLink.slug.repo}` + : (repo?.displayName ?? translate('auto.components.TaskPage.repository', 'Repository')) const resolvedStatusStateDraft = resolveTaskPageGitHubStatusStateDraft(statusStateDraft, item) if (resolvedStatusStateDraft !== statusStateDraft) { @@ -1001,38 +1114,71 @@ function GHStatusCell({ ) const handleStateChange = useCallback( - (newState: 'open' | 'closed') => { - if (newState === localState || !repo || item.type !== 'issue') { + (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { + if (newState === localState || item.type !== 'issue') { + return + } + const parsedOwnerRepo = parsedIssueLink?.slug + if (!repo && !parsedOwnerRepo) { return } reqRef.current += 1 const reqId = reqRef.current + const updates: GitHubIssueUpdate = + newState === 'closed' && closeAction + ? buildTaskPageGitHubCloseUpdate(closeAction) + : { state: newState } updateLocalState(newState) 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 }>( + // Why: issue rows can be sourced by owner/repo URL instead of the local + // repo context; slug-aware writes preserve close reasons and duplicates. + const updatePromise = parsedOwnerRepo + ? target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: { message?: string } | string }>( target, - 'github.updateIssue', - { repo: runtimeRepoId, number: item.number, updates: { state: newState } }, + 'github.project.updateIssueBySlug', + { + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, + number: item.number, + updates + }, { timeoutMs: 30_000 } ) - : window.api.gh.updateIssue({ - repoPath: repo.path, - repoId: repo.id, - sourceContext, + : window.api.gh.updateIssueBySlug({ + owner: parsedOwnerRepo.owner, + repo: parsedOwnerRepo.repo, number: item.number, - updates: { state: newState } + updates }) + : (() => { + if (!repo) { + throw new Error('No GitHub repository context available for this issue.') + } + const runtimeRepoId = + sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id + return target.kind === 'environment' + ? callRuntimeRpc<{ ok?: boolean; error?: string }>( + target, + 'github.updateIssue', + { repo: runtimeRepoId, number: item.number, updates }, + { timeoutMs: 30_000 } + ) + : window.api.gh.updateIssue({ + repoPath: repo.path, + repoId: repo.id, + sourceContext, + number: item.number, + updates + }) + })() updatePromise .then((result) => { if (reqId !== reqRef.current) { return } - const typed = result as { ok?: boolean; error?: string } + const typed = result as { ok?: boolean; error?: string | { message?: string } } if (typed && typed.ok === false) { updateLocalState(newState === 'closed' ? 'open' : 'closed') patchWorkItem( @@ -1042,11 +1188,16 @@ function GHStatusCell({ { sourceContext } ) toast.error( - typed.error ?? - translate('auto.components.TaskPage.1c893195ac', 'Failed to update state') + typeof typed.error === 'string' + ? typed.error + : (typed.error?.message ?? + translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) ) return } + if (repo) { + useAppStore.getState().evictGitHubRepoCaches(repo.id, repo.path) + } useAppStore.getState().recordFeatureInteraction('github-tasks') }) .catch(() => { @@ -1065,65 +1216,236 @@ function GHStatusCell({ toast.error(translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')) }) }, - [item, localState, patchWorkItem, repo, sourceContext, sourceSettings, updateLocalState] + [ + item, + localState, + parsedIssueLink, + patchWorkItem, + repo, + sourceContext, + sourceSettings, + updateLocalState + ] ) - if (item.type !== 'issue' || !repo) { - return ( - - {translate('auto.components.TaskPage.606a85c774', 'Open')} - - ) + const closeAsDuplicate = useCallback( + (targetIssueNumber: number | string) => { + const validation = validateTaskPageGitHubDuplicateTarget( + String(targetIssueNumber), + item.number + ) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + setDuplicateError(null) + handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) + setOpen(false) + setDuplicatePickerOpen(false) + }, + [handleStateChange, item.number] + ) + + const handleDuplicateSearchSubmit = useCallback(() => { + const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + closeAsDuplicate(validation.duplicateOf) + }, [closeAsDuplicate, duplicateSearch, item.number]) + + const handlePopoverOpenChange = useCallback((nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + } + }, []) + + if (item.type !== 'issue' || (!repo && !parsedIssueLink?.slug)) { + return } return ( - + - e.stopPropagation()}> - - + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {duplicatePickerOpen ? ( +
+
+ + + {duplicatePickerTitle} + +
+
+ + { + setDuplicateSearch(event.target.value) + setDuplicateError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

+ ) : null} +
+ {directDuplicateTarget ? ( + + ) : null} + {filteredDuplicateCandidates.map((candidate) => ( + + ))} + {!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? ( +

+ {translate( + 'auto.components.TaskPage.noMatchingIssuesLoaded', + 'No matching issues loaded.' + )} +

+ ) : null} +
+
+ ) : ( + <> + + + + + + )}
) @@ -1143,47 +1465,27 @@ function formatPRDelta(item: GitHubWorkItem): string | null { return parts.length > 0 ? parts.join(' ') : null } -function getReviewTone(item: GitHubWorkItem): string { - if (item.reviewDecision === 'APPROVED') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' - } - if (item.reviewDecision === 'CHANGES_REQUESTED') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200' - } - if (item.reviewRequests && item.reviewRequests.length > 0) { - return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200' - } - return 'border-border/60 bg-background/70 text-muted-foreground' -} - function ReviewChipAvatar({ reviewer }: { reviewer: GitHubPRPrimaryReviewer | null }): React.JSX.Element { - if (reviewer?.avatarUrl) { + if (reviewer?.login) { + // Why: `gh pr list --json reviewRequests` can return only logins; GitHub's + // public avatar endpoint keeps the list visual aligned with assignee cells. + const avatarUrl = reviewer.avatarUrl || `https://github.com/${reviewer.login}.png?size=40` return ( ) } - if (reviewer?.login) { - return ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - ) - } - return + return } function GitHubAssigneeAvatar({ assignee }: { assignee: GitHubAssignableUser }): React.JSX.Element { @@ -1679,16 +1981,16 @@ function getChecksLabel(item: GitHubWorkItem): string { return `${summary.passed}/${summary.total} passed` } -function getChecksTone(item: GitHubWorkItem): string { +function getChecksPillTone(item: GitHubWorkItem): string { const state = item.checksSummary?.state if (state === 'success') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200' + return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200' } if (state === 'failure') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200' + return 'border-rose-500/40 bg-rose-500/10 text-rose-200' } if (state === 'pending') { - return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200' + return 'border-amber-500/40 bg-amber-500/10 text-amber-200' } return 'border-border/60 bg-background/70 text-muted-foreground' } @@ -1757,6 +2059,8 @@ function PRReviewCell({ const [localReviewRequests, setLocalReviewRequests] = useState( () => item.reviewRequests ?? [] ) + const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') + const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ itemId: item.id, repoId: item.repoId, @@ -1779,6 +2083,7 @@ function PRReviewCell({ [repoOwnerSettings, sourceContext] ) const reviewerInputRef = useRef(null) + const reviewerTriggerRef = useRef(null) const reviewerInputFocusFrameRef = useRef(null) const cancelReviewerInputFocusFrame = useCallback((): void => { @@ -1937,6 +2242,8 @@ function PRReviewCell({ const itemWithLocalReviewRequests = { ...item, reviewRequests: localReviewRequests } const primaryReviewer = getGitHubPRPrimaryReviewer(itemWithLocalReviewRequests) + const reviewerRows = getGitHubPRReviewerRows(itemWithLocalReviewRequests) + const extraReviewerCount = Math.max(0, reviewerRows.length - 1) const hasReviewerMetadata = item.reviewDecision !== undefined || localReviewRequests.length > 0 || @@ -2071,6 +2378,16 @@ function PRReviewCell({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { + if (nextOpen) { + const rect = reviewerTriggerRef.current?.getBoundingClientRect() + const gap = 8 + const availableBelow = rect ? window.innerHeight - rect.bottom - gap : 0 + const availableAbove = rect ? rect.top - gap : 0 + const nextSide = availableBelow < 240 && availableAbove > availableBelow ? 'top' : 'bottom' + const available = nextSide === 'top' ? availableAbove : availableBelow + setReviewerPickerSide(nextSide) + setReviewerPickerMaxHeight(Math.max(180, Math.min(360, available || 360))) + } setOpen(nextOpen) if (nextOpen) { cancelReviewerInputFocusFrame() @@ -2139,20 +2456,44 @@ function PRReviewCell({ event.stopPropagation()} onOpenAutoFocus={(event) => { event.preventDefault() @@ -2204,7 +2545,7 @@ function PRReviewCell({ }} />
-
+
{reviewerMetadata.loading ? (
{translate('auto.components.TaskPage.0eacf48491', 'Loading…')} @@ -2320,7 +2661,7 @@ function PRChecksCell({ }} className={cn( 'inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-110', - getChecksTone(item) + getChecksPillTone(item) )} > @@ -2499,7 +2840,7 @@ function PRMergeCell({ )} > {merging ? ( - + ) : ( )} @@ -4121,7 +4462,8 @@ export default function TaskPage(): React.JSX.Element { // Jira tab state const [jiraIssues, setJiraIssues] = useState([]) const [jiraLoading, setJiraLoading] = useState(false) - const [jiraError, setJiraError] = useState(null) + const [jiraError, setJiraError] = useState(null) + const [jiraErrorDetailsOpen, setJiraErrorDetailsOpen] = useState(false) const [jiraSearchInput, setJiraSearchInput] = useState('') const [appliedJiraSearch, setAppliedJiraSearch] = useState('') const [activeJiraPreset, setActiveJiraPreset] = useState('assigned') @@ -5237,6 +5579,7 @@ export default function TaskPage(): React.JSX.Element { ) const activeGithubTaskKind = getGitHubTaskKind(activeTaskPreset, appliedTaskSearch) + const appliedTaskQuery = useMemo(() => parseTaskQuery(appliedTaskSearch), [appliedTaskSearch]) const selectedGitHubRepoExternalLink = useMemo(() => { if (selectedRepos.length !== 1) { return null @@ -6009,11 +6352,18 @@ export default function TaskPage(): React.JSX.Element { setTaskRefreshNonce((current) => current + 1) }, [activeGithubTaskKind, setTaskResumeState, taskSearchInput]) - const handleTaskSearchChange = useCallback((event: React.ChangeEvent): void => { - const next = event.target.value - setTaskSearchInput(next) - setActiveTaskPreset(null) - }, []) + const handleTaskSearchChange = useCallback( + (event: React.ChangeEvent): void => { + const next = event.target.value + const scoped = scopeGitHubTaskSearch(next, activeGithubTaskKind) + setTaskSearchInput(next) + setActiveTaskPreset(null) + // Why: the visible rows are keyed by appliedTaskSearch, not the draft + // input. Hide stale rows as soon as the draft would change the query. + setTasksFiltering(scoped !== appliedTaskSearch) + }, + [activeGithubTaskKind, appliedTaskSearch] + ) const handleSetDefaultTaskPreset = useCallback( (presetId: TaskViewPresetId): void => { @@ -7231,6 +7581,7 @@ export default function TaskPage(): React.JSX.Element { let cancelled = false setJiraLoading(true) setJiraError(null) + setJiraErrorDetailsOpen(false) const trimmed = appliedJiraSearch.trim() const request = @@ -7252,7 +7603,9 @@ export default function TaskPage(): React.JSX.Element { if (cancelled) { return } - setJiraError(err instanceof Error ? err.message : 'Failed to load Jira issues.') + const failureState = createTaskPageJiraLoadFailureState(err) + setJiraIssues(failureState.issues) + setJiraError(failureState.error) setJiraLoading(false) }) @@ -7706,91 +8059,84 @@ export default function TaskPage(): React.JSX.Element { })}
) : null} - {/* Why: the repo combobox filters Items mode by repo. In - Project mode the row set comes from the project's - view filter (server-side), so this control would be - inert — hide it to avoid suggesting it does - something. */} - {githubMode !== 'project' && ( - <> -
- { - 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(taskPickerRepos.map((r) => r.id)) - setRepoSelection(allIds) - void updateSettings({ defaultRepoSelection: null }).catch(() => { - toast.error( - translate( - 'auto.components.TaskPage.dfd72673e7', - 'Failed to save project selection.' - ) + {/* Why: Project rows are now repo-scoped too, so the + selection must stay visible in both GitHub modes. */} +
+ { + const normalized = normalizeTaskRepoSelection(eligibleRepos, next) + setRepoSelection(normalized) + void updateSettings({ defaultRepoSelection: [...normalized] }).catch( + () => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' ) - }) - }} - triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" - /> -
- - - - - - {selectedGitHubRepoExternalLink + ) + } + ) + }} + onSelectAll={() => { + const allIds = new Set(taskPickerRepos.map((r) => r.id)) + setRepoSelection(allIds) + void updateSettings({ defaultRepoSelection: null }).catch(() => { + toast.error( + translate( + 'auto.components.TaskPage.dfd72673e7', + 'Failed to save project selection.' + ) + ) + }) + }} + triggerClassName="h-8 w-auto max-w-[220px] rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" + /> +
+ + + + + + {selectedGitHubRepoExternalLink + ? translate( + 'auto.components.TaskPage.8d1e17a3ef', + 'Open {{value0}} in GitHub', + { value0: selectedGitHubRepoExternalLink.label } + ) + : translate( + 'auto.components.TaskPage.bc46d8204e', + 'Select one project to open in GitHub' + )} + +
) : null} @@ -7835,7 +8181,7 @@ export default function TaskPage(): React.JSX.Element {
- +
) : taskSource === 'github' ? (
@@ -8763,6 +9109,29 @@ export default function TaskPage(): React.JSX.Element { const attachedWorkspaceLabel = attachedWorkspace ? getGithubWorkItemWorkspaceAttachmentLabel(attachedWorkspace) : null + const githubTaskIdPill = ( + + {item.type === 'pr' ? ( + isTaskPageGitHubDraftPR(item) ? ( + + ) return ( // Why: the row is a clickable container rather than a // )} @@ -1564,12 +1833,11 @@ export default function CombinedDiffViewer({
{skippedConflictNotice} -
+
{virtualizer.getVirtualItems().map((virtualItem) => { const section = sections[virtualItem.index] if (!section) { @@ -1580,6 +1848,8 @@ export default function CombinedDiffViewer({
{ + const prewarm = prewarmMarkdownPreviewLocalImages(renderedContent, filePath, { + runtimeContext: imageRuntimeContext + }) + return prewarm.cancel + }, [renderedContent, filePath, imageRuntimeContext]) + const frontMatter = useMemo(() => extractFrontMatter(renderedContent), [renderedContent]) const tableOfContentsItems = useMemo( () => buildMarkdownTableOfContents(renderedContent), diff --git a/src/renderer/src/components/editor/combined-diff-entries.test.ts b/src/renderer/src/components/editor/combined-diff-entries.test.ts index 2aa4935564c..859cd92d882 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.test.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.test.ts @@ -35,7 +35,7 @@ describe('getCombinedUncommittedEntries', () => { ]) }) - it('excludes untracked entries when no area filter is set', () => { + it('includes every area when no area filter is set', () => { const liveEntries: GitStatusEntry[] = [ { path: 'src/staged.ts', status: 'modified', area: 'staged' }, { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' }, @@ -44,7 +44,8 @@ describe('getCombinedUncommittedEntries', () => { expect(getCombinedUncommittedEntries(liveEntries, undefined)).toEqual([ { path: 'src/staged.ts', status: 'modified', area: 'staged' }, - { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' } + { path: 'src/unstaged.ts', status: 'modified', area: 'unstaged' }, + { path: 'src/untracked.ts', status: 'untracked', area: 'untracked' } ]) }) }) diff --git a/src/renderer/src/components/editor/combined-diff-entries.ts b/src/renderer/src/components/editor/combined-diff-entries.ts index c2ca3526d8c..e27e5037644 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.ts +++ b/src/renderer/src/components/editor/combined-diff-entries.ts @@ -15,10 +15,7 @@ export function getCombinedUncommittedEntries( if (entry.conflictStatus === 'unresolved') { return false } - if (areaFilter) { - return entry.area === areaFilter - } - return entry.area !== 'untracked' + return areaFilter === undefined || entry.area === areaFilter }) } diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts index 5f92d27f6d9..cbffbbc51e3 100644 --- a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts +++ b/src/renderer/src/components/editor/combined-diff-file-tree-model.ts @@ -2,7 +2,7 @@ import { basename } from '@/lib/path' import type { GitBranchChangeEntry, GitStatusEntry } from '../../../../shared/types' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -export type CombinedDiffFileTreeMode = 'uncommitted' | 'branch' | 'commit' +export type CombinedDiffFileTreeMode = 'all' | 'uncommitted' | 'branch' | 'commit' export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit' @@ -20,10 +20,10 @@ export function getCombinedDiffFileTreeSectionKey( mode: CombinedDiffFileTreeMode, entry: CombinedDiffFileTreeEntry ): string { - if (mode === 'uncommitted' && 'area' in entry) { + if ((mode === 'all' || mode === 'uncommitted') && 'area' in entry) { return `${entry.area}:${entry.path}` } - return `${mode === 'branch' ? 'combined-branch' : 'combined-commit'}:${entry.path}` + return `${mode === 'commit' ? 'combined-commit' : 'combined-branch'}:${entry.path}` } export function createCombinedDiffSectionIndexMap( diff --git a/src/renderer/src/components/editor/editor-header.test.ts b/src/renderer/src/components/editor/editor-header.test.ts index 7cb9e26deec..0939bc8dae0 100644 --- a/src/renderer/src/components/editor/editor-header.test.ts +++ b/src/renderer/src/components/editor/editor-header.test.ts @@ -98,7 +98,7 @@ describe('getEditorHeaderCopyState', () => { filePath: '/repo/worktree', relativePath: 'All Changes', mode: 'diff', - diffSource: 'combined-uncommitted' + diffSource: 'combined-all' }) ) ).toEqual({ diff --git a/src/renderer/src/components/editor/editor-header.ts b/src/renderer/src/components/editor/editor-header.ts index 9cfc39fe495..a3fbafbd6da 100644 --- a/src/renderer/src/components/editor/editor-header.ts +++ b/src/renderer/src/components/editor/editor-header.ts @@ -35,7 +35,8 @@ export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState const isCombinedDiff = file.mode === 'diff' && - (file.diffSource === 'combined-uncommitted' || + (file.diffSource === 'combined-all' || + file.diffSource === 'combined-uncommitted' || file.diffSource === 'combined-branch' || file.diffSource === 'combined-commit') @@ -66,6 +67,7 @@ export function getEditorHeaderOpenFileState( const isSingleDiff = file.mode === 'diff' && file.diffSource !== undefined && + file.diffSource !== 'combined-all' && file.diffSource !== 'combined-uncommitted' && file.diffSource !== 'combined-branch' && file.diffSource !== 'combined-commit' diff --git a/src/renderer/src/components/editor/editor-labels.ts b/src/renderer/src/components/editor/editor-labels.ts index 50e55931db3..c39f38abe36 100644 --- a/src/renderer/src/components/editor/editor-labels.ts +++ b/src/renderer/src/components/editor/editor-labels.ts @@ -42,9 +42,12 @@ export function getEditorDisplayLabel( } const source = file.diffSource - if (source === 'combined-uncommitted') { + if (source === 'combined-all') { return 'All Changes' } + if (source === 'combined-uncommitted') { + return file.combinedAreaFilter ? getBaseLabel(file, variant) : 'Uncommitted Changes' + } if (source === 'combined-branch') { return `Branch Changes (${file.branchCompare?.baseRef ?? 'base'})` } diff --git a/src/renderer/src/components/editor/editor-panel-diff-reload.ts b/src/renderer/src/components/editor/editor-panel-diff-reload.ts index ed33a5cbf31..4b58a471239 100644 --- a/src/renderer/src/components/editor/editor-panel-diff-reload.ts +++ b/src/renderer/src/components/editor/editor-panel-diff-reload.ts @@ -5,6 +5,7 @@ export function isReloadableSingleFileDiffTab(file: OpenFile): boolean { return ( file.mode === 'diff' && file.diffSource !== undefined && + file.diffSource !== 'combined-all' && file.diffSource !== 'combined-uncommitted' && file.diffSource !== 'combined-branch' && file.diffSource !== 'combined-commit' diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 85463f36d64..be9738db6a4 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -40,12 +40,14 @@ export function getEditorPanelRenderModel({ const isSingleDiff = activeFile.mode === 'diff' && activeFile.diffSource !== undefined && + activeFile.diffSource !== 'combined-all' && activeFile.diffSource !== 'combined-uncommitted' && activeFile.diffSource !== 'combined-branch' && activeFile.diffSource !== 'combined-commit' const isCombinedDiff = activeFile.mode === 'diff' && - (activeFile.diffSource === 'combined-uncommitted' || + (activeFile.diffSource === 'combined-all' || + activeFile.diffSource === 'combined-uncommitted' || activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') const resolvedLanguage = diff --git a/src/renderer/src/components/editor/markdown-preview-controls.ts b/src/renderer/src/components/editor/markdown-preview-controls.ts index df0762ff483..fc6ef9f5c1b 100644 --- a/src/renderer/src/components/editor/markdown-preview-controls.ts +++ b/src/renderer/src/components/editor/markdown-preview-controls.ts @@ -44,6 +44,7 @@ export function getMarkdownViewModes(target: MarkdownPreviewTarget): readonly Ma } if ( target.mode === 'diff' && + target.diffSource !== 'combined-all' && target.diffSource !== 'combined-uncommitted' && target.diffSource !== 'combined-branch' && target.diffSource !== 'combined-commit' diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.test.ts b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts new file mode 100644 index 00000000000..f7d60703fb3 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' +import { + MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY, + MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT, + extractMarkdownPreviewLocalImageCandidates, + prewarmMarkdownPreviewLocalImages, + type MarkdownPreviewLocalImageCandidate +} from './markdown-preview-local-images' + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function markdownImages(count: number): string { + return Array.from({ length: count }, (_, index) => `![${index}](images/${index}.png)`).join('\n') +} + +describe('extractMarkdownPreviewLocalImageCandidates', () => { + it('extracts direct, reference-style, and GFM table images in document order', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + [ + '![Direct](./direct.png)', + '![Reference][logo]', + '', + '| Asset |', + '| --- |', + '| ![Table](table.png) |', + '', + '[logo]: ../assets/logo.png' + ].join('\n'), + '/repo/docs/readme.md' + ) + + expect(candidates.map((candidate) => candidate.absolutePath)).toEqual([ + '/repo/docs/direct.png', + '/repo/assets/logo.png', + '/repo/docs/table.png' + ]) + }) + + it('resolves Windows drive-letter and percent-escaped paths through image path helpers', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + ['![Drive]()', '![Escaped](./space%20name.png)'].join('\n'), + 'C:\\repo\\docs\\readme.md' + ) + + expect(candidates.map((candidate) => candidate.absolutePath)).toEqual([ + 'C:/repo/assets/diagram.png', + 'C:/repo/docs/space name.png' + ]) + }) + + it('resolves relative image paths from UNC markdown files through image path helpers', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + '![Unc](./unc.png)', + '\\\\server\\share\\repo\\docs\\readme.md' + ) + + expect(candidates.map((candidate) => candidate.absolutePath)).toEqual([ + '//server/share/repo/docs/unc.png' + ]) + }) + + it('skips external, data, blob, raw HTML, and unsupported-scheme images', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + [ + '![Http](https://example.com/image.png)', + '![Data](data:image/png;base64,abc)', + '![Blob](blob:abc)', + '![Mail](mailto:image@example.com)', + '', + '![Local](./local.png)' + ].join('\n'), + '/repo/docs/readme.md' + ) + + expect(candidates.map((candidate) => candidate.absolutePath)).toEqual(['/repo/docs/local.png']) + }) + + it('dedupes by cache key after dropping query strings and fragments', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + ['![One](./logo.png?v=1#one)', '![Two](./logo.png?v=2#two)'].join('\n'), + '/repo/docs/readme.md' + ) + + expect(candidates).toHaveLength(1) + expect(candidates[0]?.absolutePath).toBe('/repo/docs/logo.png') + }) + + it('caps candidates below the blob cache size', () => { + const candidates = extractMarkdownPreviewLocalImageCandidates( + markdownImages(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT + 10), + '/repo/docs/readme.md' + ) + + expect(candidates).toHaveLength(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT) + expect(candidates).toHaveLength(64) + }) + + it('uses runtime owner data when deduping cache keys', () => { + const firstOwner = extractMarkdownPreviewLocalImageCandidates( + '![Logo](./logo.png)', + '/repo/docs/readme.md', + { + runtimeContext: { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + } + } + ) + const secondOwner = extractMarkdownPreviewLocalImageCandidates( + '![Logo](./logo.png)', + '/repo/docs/readme.md', + { + runtimeContext: { + settings: { activeRuntimeEnvironmentId: 'env-2' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + } + } + ) + + expect(firstOwner[0]?.cacheKey).not.toBe(secondOwner[0]?.cacheKey) + }) +}) + +describe('prewarmMarkdownPreviewLocalImages', () => { + it('prewarms capped, deduped candidates through the shared loader boundary', async () => { + const loaded: MarkdownPreviewLocalImageCandidate[] = [] + const prewarm = prewarmMarkdownPreviewLocalImages( + ['![One](./one.png)', '![Duplicate](./one.png?v=1)', '![Two](./two.png)'].join('\n'), + '/repo/docs/readme.md', + { + loadImage: (candidate) => { + loaded.push(candidate) + return Promise.resolve(null) + } + } + ) + + await prewarm.done + + expect(loaded.map((candidate) => candidate.absolutePath)).toEqual([ + '/repo/docs/one.png', + '/repo/docs/two.png' + ]) + }) + + it('does not enqueue more than the candidate cap', async () => { + const loaded: MarkdownPreviewLocalImageCandidate[] = [] + const prewarm = prewarmMarkdownPreviewLocalImages( + markdownImages(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT + 5), + '/repo/docs/readme.md', + { + concurrency: MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT, + loadImage: (candidate) => { + loaded.push(candidate) + return Promise.resolve(null) + } + } + ) + + await prewarm.done + + expect(loaded).toHaveLength(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT) + }) + + it('never starts more reads than the concurrency limit at once', async () => { + const releaseNext: (() => void)[] = [] + const started: string[] = [] + let active = 0 + let maxActive = 0 + + const prewarm = prewarmMarkdownPreviewLocalImages(markdownImages(6), '/repo/docs/readme.md', { + concurrency: 2, + loadImage: (candidate) => { + started.push(candidate.absolutePath) + active += 1 + maxActive = Math.max(maxActive, active) + return new Promise((resolve) => { + releaseNext.push(() => { + active -= 1 + resolve(null) + }) + }) + } + }) + + expect(started).toHaveLength(2) + releaseNext.shift()?.() + await flushPromises() + expect(started).toHaveLength(3) + + while (releaseNext.length > 0) { + releaseNext.shift()?.() + await flushPromises() + } + await prewarm.done + + expect(maxActive).toBe(2) + expect(started).toHaveLength(6) + }) + + it('uses the documented default concurrency', async () => { + const pending: (() => void)[] = [] + const started: string[] = [] + + const prewarm = prewarmMarkdownPreviewLocalImages(markdownImages(10), '/repo/docs/readme.md', { + loadImage: (candidate) => { + started.push(candidate.absolutePath) + return new Promise((resolve) => { + pending.push(() => resolve(null)) + }) + } + }) + + expect(started).toHaveLength(MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY) + prewarm.cancel() + for (const release of pending) { + release() + } + await prewarm.done + }) + + it('stops scheduling not-yet-started work after cancellation', async () => { + const pending: (() => void)[] = [] + const started: string[] = [] + const prewarm = prewarmMarkdownPreviewLocalImages(markdownImages(5), '/repo/docs/readme.md', { + concurrency: 2, + loadImage: (candidate) => { + started.push(candidate.absolutePath) + return new Promise((resolve) => { + pending.push(() => resolve(null)) + }) + } + }) + + expect(started).toHaveLength(2) + prewarm.cancel() + for (const release of pending) { + release() + } + await prewarm.done + + expect(started).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.ts b/src/renderer/src/components/editor/markdown-preview-local-images.ts new file mode 100644 index 00000000000..57d37f09d9e --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-local-images.ts @@ -0,0 +1,196 @@ +import remarkFrontmatter from 'remark-frontmatter' +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import { unified } from 'unified' +import { resolveImageAbsolutePath } from './markdown-preview-links' +import { getLocalImageCacheKey, loadLocalImageAbsolutePath } from './useLocalImageSrc' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' + +export const MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT = 64 +export const MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY = 4 + +type MarkdownImageRuntimeContext = Omit & { + connectionId?: string | null +} + +export type MarkdownPreviewLocalImageCandidate = { + absolutePath: string + cacheKey: string + rawSrc: string +} + +type MarkdownImageAstNode = { + children?: MarkdownImageAstNode[] + identifier?: string + label?: string + type?: string + url?: string +} + +type ExtractLocalImageCandidatesOptions = { + connectionId?: string | null + limit?: number + runtimeContext?: MarkdownImageRuntimeContext +} + +type PrewarmMarkdownPreviewLocalImagesOptions = ExtractLocalImageCandidatesOptions & { + concurrency?: number + loadImage?: (candidate: MarkdownPreviewLocalImageCandidate) => Promise +} + +export type MarkdownPreviewLocalImagePrewarm = { + cancel: () => void + done: Promise +} + +function normalizeReferenceIdentifier(identifier: string): string { + return identifier.trim().replace(/\s+/g, ' ').toLowerCase() +} + +function collectImageDefinitions( + node: MarkdownImageAstNode, + definitions: Map +): void { + if ( + node.type === 'definition' && + typeof node.identifier === 'string' && + typeof node.url === 'string' + ) { + definitions.set(normalizeReferenceIdentifier(node.identifier), node.url) + } + for (const child of node.children ?? []) { + collectImageDefinitions(child, definitions) + } +} + +export function extractMarkdownPreviewLocalImageCandidates( + markdown: string, + filePath: string, + options: ExtractLocalImageCandidatesOptions = {} +): MarkdownPreviewLocalImageCandidate[] { + const limit = Math.max(0, options.limit ?? MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT) + if (limit === 0) { + return [] + } + + // Why: parser extraction matches rendered Markdown image/reference/table + // nodes without scanning raw text or accidentally prewarming HTML tags. + const tree = unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkFrontmatter, ['yaml', 'toml']) + .parse(markdown) as MarkdownImageAstNode + const definitions = new Map() + const candidates: MarkdownPreviewLocalImageCandidate[] = [] + const seenCacheKeys = new Set() + + collectImageDefinitions(tree, definitions) + + function appendRawSrc(rawSrc: string): void { + if (candidates.length >= limit) { + return + } + const absolutePath = resolveImageAbsolutePath(rawSrc, filePath) + if (!absolutePath) { + return + } + const cacheKey = getLocalImageCacheKey( + absolutePath, + options.connectionId, + options.runtimeContext + ) + if (seenCacheKeys.has(cacheKey)) { + return + } + seenCacheKeys.add(cacheKey) + candidates.push({ absolutePath, cacheKey, rawSrc }) + } + + function visit(node: MarkdownImageAstNode): void { + if (candidates.length >= limit) { + return + } + if (node.type === 'image' && typeof node.url === 'string') { + appendRawSrc(node.url) + } else if (node.type === 'imageReference' && typeof node.identifier === 'string') { + const rawSrc = definitions.get(normalizeReferenceIdentifier(node.identifier)) + if (rawSrc) { + appendRawSrc(rawSrc) + } + } + for (const child of node.children ?? []) { + visit(child) + } + } + + visit(tree) + return candidates +} + +export function prewarmMarkdownPreviewLocalImages( + markdown: string, + filePath: string, + options: PrewarmMarkdownPreviewLocalImagesOptions = {} +): MarkdownPreviewLocalImagePrewarm { + const candidates = extractMarkdownPreviewLocalImageCandidates(markdown, filePath, options) + const concurrency = Math.max( + 1, + options.concurrency ?? MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_CONCURRENCY + ) + const loadImage = + options.loadImage ?? + ((candidate: MarkdownPreviewLocalImageCandidate) => + loadLocalImageAbsolutePath( + candidate.absolutePath, + options.connectionId, + options.runtimeContext + )) + let cancelled = false + let nextIndex = 0 + let activeCount = 0 + let resolveDone!: () => void + const done = new Promise((resolve) => { + resolveDone = resolve + }) + + const settleIfFinished = (): void => { + if ((cancelled || nextIndex >= candidates.length) && activeCount === 0) { + resolveDone() + } + } + + const scheduleNext = (): void => { + while (!cancelled && activeCount < concurrency && nextIndex < candidates.length) { + const candidate = candidates[nextIndex] + nextIndex += 1 + if (!candidate) { + continue + } + activeCount += 1 + let loadPromise: Promise + try { + loadPromise = loadImage(candidate) + } catch { + loadPromise = Promise.resolve() + } + loadPromise + .catch(() => undefined) + .finally(() => { + activeCount -= 1 + scheduleNext() + settleIfFinished() + }) + } + settleIfFinished() + } + + scheduleNext() + + return { + cancel: () => { + cancelled = true + settleIfFinished() + }, + done + } +} diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index cd0ade29020..551902aee27 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -152,6 +152,12 @@ describe('rich markdown round trip', () => { expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a') }) + it('preserves encoded local image paths with screenshot filenames', () => { + expect(roundTripMarkdown('![](Screenshot%202026-06-22%20at%203.37.19%20PM%20copy.png)\n')).toBe( + '![](Screenshot%202026-06-22%20at%203.37.19%20PM%20copy.png)' + ) + }) + it('does not surface Linear issue reference definitions as description text', () => { const input = [ '- [x] [H-279]', diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts index 060a69d5d15..0379a925563 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.test.ts @@ -93,6 +93,33 @@ describe('insertRichMarkdownImageFromPath', () => { ) }) + it('inserts markdown-safe image src values for screenshot filenames with spaces', async () => { + vi.mocked(importExternalPathsToRuntime).mockResolvedValue({ + results: [ + { + status: 'imported', + destPath: '/repo/Screenshot 2026-06-22 at 3.37.19 PM copy.png' + } + ] + } as never) + const { editor, insertContentAt } = editorWithRunResult(true) + + await insertRichMarkdownImageFromPath({ + editor: editor as never, + filePath: '/repo/note.md', + sourcePath: '/tmp/image.png', + worktreeId: 'wt-1', + insertPos: 4 + }) + + expect(insertContentAt).toHaveBeenCalledWith(4, { + type: 'image', + attrs: { + src: 'Screenshot%202026-06-22%20at%203.37.19%20PM%20copy.png' + } + }) + }) + it('skips editor mutation when the caller rejects the stale target after import', async () => { const { editor, chain } = editorWithRunResult(true) diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.ts index 89a48e07b27..216434bdeab 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.ts @@ -66,10 +66,11 @@ export async function insertRichMarkdownImageFromPath({ return } + const imageSrc = encodeMarkdownImageBasename(imported.destPath) const inserted = editor .chain() .focus() - .insertContentAt(insertPos, { type: 'image', attrs: { src: basename(imported.destPath) } }) + .insertContentAt(insertPos, { type: 'image', attrs: { src: imageSrc } }) .run() if (!inserted) { toast.error( @@ -81,6 +82,12 @@ export async function insertRichMarkdownImageFromPath({ } } +function encodeMarkdownImageBasename(destPath: string): string { + // Why: unescaped spaces and delimiters in markdown image destinations make + // screenshot filenames render as literal text or broken partial paths. + return encodeURIComponent(basename(destPath)) +} + function getWorktreePath(worktreeId: string | null): string | null { if (!worktreeId) { return null diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index a922cd5e93f..98907f2709a 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -1,8 +1,73 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getLocalImageCacheKey, loadLocalImageSrc } from './useLocalImageSrc' +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getLocalImageCacheKey, + invalidateLocalImageSrcCacheForTests, + loadLocalImageSrc, + resetLocalImageSrcStateForTests, + useLocalImageSrc +} from './useLocalImageSrc' + +type PreviewResult = { + content: string + isBinary: boolean + mimeType?: string +} + +function deferred(): { + promise: Promise + reject: (error: unknown) => void + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, reject, resolve } +} + +function binaryPreview(content = 'AA=='): PreviewResult { + return { content, isBinary: true, mimeType: 'image/png' } +} + +function setReadFile(readFile: ReturnType): void { + globalThis.window.api = { + fs: { readFile } + } as unknown as Window['api'] +} + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +function HookProbe({ + filePath, + onRender, + src +}: { + filePath: string + onRender: (displaySrc: string | undefined) => void + src: string +}): null { + onRender(useLocalImageSrc(src, filePath)) + return null +} + +beforeEach(() => { + resetLocalImageSrcStateForTests() + vi.spyOn(URL, 'createObjectURL').mockReset() + vi.spyOn(URL, 'revokeObjectURL').mockReset() +}) afterEach(() => { - vi.unstubAllGlobals() + resetLocalImageSrcStateForTests() + vi.restoreAllMocks() }) describe('getLocalImageCacheKey', () => { @@ -26,6 +91,42 @@ describe('getLocalImageCacheKey', () => { expect(localKey).not.toBe(remoteKey) expect(remoteKey).not.toBe(otherRemoteKey) }) +}) + +describe('loadLocalImageSrc', () => { + it('shares one pending read and one blob URL for duplicate local image loads', async () => { + const read = deferred() + const readFile = vi.fn().mockReturnValue(read.promise) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:local-image') + setReadFile(readFile) + + const first = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + const second = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + + expect(readFile).toHaveBeenCalledTimes(1) + read.resolve(binaryPreview()) + + await expect(Promise.all([first, second])).resolves.toEqual([ + 'blob:local-image', + 'blob:local-image' + ]) + expect(URL.createObjectURL).toHaveBeenCalledTimes(1) + }) + + it('clears failed in-flight reads so a later retry can succeed', async () => { + const readFile = vi + .fn() + .mockRejectedValueOnce(new Error('denied')) + .mockResolvedValueOnce(binaryPreview()) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:retry') + setReadFile(readFile) + + await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBeNull() + await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBe( + 'blob:retry' + ) + expect(readFile).toHaveBeenCalledTimes(2) + }) it('does not fall back to raw local src when IPC returns non-binary content', async () => { const readFile = vi.fn().mockResolvedValue({ @@ -33,11 +134,7 @@ describe('getLocalImageCacheKey', () => { content: '', mimeType: 'image/svg+xml' }) - vi.stubGlobal('window', { - api: { - fs: { readFile } - } - }) + setReadFile(readFile) await expect(loadLocalImageSrc('diagram.svg', '/repo/docs/readme.md')).resolves.toBeNull() expect(readFile).toHaveBeenCalledWith({ @@ -47,40 +144,112 @@ describe('getLocalImageCacheKey', () => { }) it('does not fall back to raw local src when IPC rejects the read', async () => { - vi.stubGlobal('window', { - api: { - fs: { readFile: vi.fn().mockRejectedValue(new Error('denied')) } - } - }) + setReadFile(vi.fn().mockRejectedValue(new Error('denied'))) await expect( loadLocalImageSrc('file:///repo/docs/diagram.png', '/repo/docs/readme.md') ).resolves.toBeNull() }) - it('revokes a blob URL that is overwritten by a concurrent load for the same image', async () => { - const readFile = vi.fn().mockResolvedValue({ - isBinary: true, - content: 'AA==', - mimeType: 'image/png' - }) + it('suppresses a stale pending completion after cache invalidation', async () => { + const firstRead = deferred() + const readFile = vi + .fn() + .mockReturnValueOnce(firstRead.promise) + .mockResolvedValueOnce(binaryPreview()) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:fresh') + setReadFile(readFile) + + const staleLoad = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + invalidateLocalImageSrcCacheForTests() + firstRead.resolve(binaryPreview()) + + await expect(staleLoad).resolves.toBeNull() + expect(URL.createObjectURL).not.toHaveBeenCalled() + await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBe( + 'blob:fresh' + ) + expect(readFile).toHaveBeenCalledTimes(2) + }) + + it('does not let an older invalidated read overwrite a newer successful read', async () => { + const firstRead = deferred() + const secondRead = deferred() + const readFile = vi + .fn() + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(secondRead.promise) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:newer') + setReadFile(readFile) + + const staleLoad = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + invalidateLocalImageSrcCacheForTests() + const newerLoad = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + + secondRead.resolve(binaryPreview('AQ==')) + await expect(newerLoad).resolves.toBe('blob:newer') + firstRead.resolve(binaryPreview('Ag==')) + await expect(staleLoad).resolves.toBeNull() + await expect(loadLocalImageSrc('diagram.png', '/repo/docs/readme.md')).resolves.toBe( + 'blob:newer' + ) + expect(readFile).toHaveBeenCalledTimes(2) + expect(URL.revokeObjectURL).not.toHaveBeenCalledWith('blob:newer') + }) + + it('keeps runtime owners in separate image cache entries', async () => { + const readFile = vi.fn().mockResolvedValue(binaryPreview()) vi.spyOn(URL, 'createObjectURL') - .mockReturnValueOnce('blob:local-image-first') - .mockReturnValueOnce('blob:local-image-second') - const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - vi.stubGlobal('window', { - api: { - fs: { readFile } - } + .mockReturnValueOnce('blob:runtime-one') + .mockReturnValueOnce('blob:runtime-two') + setReadFile(readFile) + + await expect( + loadLocalImageSrc('diagram.png', '/repo/docs/readme.md', null, { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }) + ).resolves.toBe('blob:runtime-one') + await expect( + loadLocalImageSrc('diagram.png', '/repo/docs/readme.md', null, { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-2', + worktreePath: '/repo', + connectionId: 'ssh-2' + }) + ).resolves.toBe('blob:runtime-two') + expect(readFile).toHaveBeenCalledTimes(2) + }) + + it('does not update mounted hook state after unmount', async () => { + const read = deferred() + const readFile = vi.fn().mockReturnValue(read.promise) + const renders: (string | undefined)[] = [] + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:unmounted') + setReadFile(readFile) + + const container = document.createElement('div') + const root: Root = createRoot(container) + await act(async () => { + root.render( + createElement(HookProbe, { + filePath: '/repo/docs/readme.md', + onRender: (displaySrc) => renders.push(displaySrc), + src: 'diagram.png' + }) + ) + }) + await act(async () => { + root.unmount() }) - const first = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') - const second = loadLocalImageSrc('diagram.png', '/repo/docs/readme.md') + read.resolve(binaryPreview()) + await act(async () => { + await flushPromises() + }) - await expect(Promise.all([first, second])).resolves.toEqual([ - 'blob:local-image-first', - 'blob:local-image-second' - ]) - expect(revokeObjectURL).toHaveBeenCalledWith('blob:local-image-first') + expect(renders).toEqual([undefined]) }) }) diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index 9d55bb7ddae..d99c55913ff 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -10,6 +10,7 @@ import { readRuntimeFilePreview } from '@/runtime/runtime-file-client' const BLOB_URL_CACHE_MAX_SIZE = 100 const blobUrlCache = new Map() +const inFlightBlobUrlLoads = new Map>() export function getLocalImageCacheKey( absolutePath: string, @@ -35,8 +36,8 @@ function cacheBlobUrl(key: string, url: string): void { if (previousUrl !== undefined) { blobUrlCache.delete(key) if (previousUrl !== url) { - // Why: concurrent loads for the same image key can both create blob URLs; - // overwriting the cache must release the superseded Blob. + // Why: cache replacements must release the superseded Blob even when + // they come from rare stale state or future loader changes. URL.revokeObjectURL(previousUrl) } } @@ -93,6 +94,7 @@ function scheduleBlobUrlRevocation(urls: string[]): void { function invalidateImageCache(): void { const staleUrls = Array.from(blobUrlCache.values()) blobUrlCache.clear() + inFlightBlobUrlLoads.clear() cacheGeneration += 1 for (const listener of cacheListeners) { listener() @@ -119,6 +121,7 @@ function disposeImageCacheModuleState(): void { URL.revokeObjectURL(url) } blobUrlCache.clear() + inFlightBlobUrlLoads.clear() cacheListeners.clear() } @@ -211,20 +214,13 @@ export function useLocalImageSrc( } let cancelled = false - readImagePreview(absolutePath, connectionId, runtimeContext) - .then((result) => { + const effectGeneration = generation + loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext) + .then((url) => { if (cancelled) { return } - if (result.isBinary && result.content) { - const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(cacheKey, url) - setDisplaySrc(url) - } else { - // Why: local image paths must stay behind IPC/runtime authorization; - // handing raw file: or relative paths back to Chromium can escape it. - setDisplaySrc(undefined) - } + setDisplaySrc(cacheGeneration === effectGeneration && url ? url : undefined) }) .catch(() => { if (!cancelled) { @@ -271,21 +267,69 @@ export async function loadLocalImageSrc( return cached } - try { - const result = await readImagePreview(absolutePath, connectionId, runtimeContext) - if (result.isBinary && result.content) { - const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') - cacheBlobUrl(cacheKey, url) - return url - } - // Why: local image paths must stay behind IPC/runtime authorization; - // callers should render a missing image instead of falling back to raw src. - return null - } catch { - // Fall through + return loadLocalImageAbsolutePath(absolutePath, connectionId, runtimeContext) +} + +export function loadLocalImageAbsolutePath( + absolutePath: string, + connectionId?: string | null, + runtimeContext?: Omit & { connectionId?: string | null } +): Promise { + const cacheKey = getLocalImageCacheKey(absolutePath, connectionId, runtimeContext) + const cached = blobUrlCache.get(cacheKey) + if (cached) { + return Promise.resolve(cached) } - return null + const inFlight = inFlightBlobUrlLoads.get(cacheKey) + if (inFlight) { + return inFlight + } + + const readGeneration = cacheGeneration + const loadPromise = readImagePreview(absolutePath, connectionId, runtimeContext) + .then((result) => { + if (!result.isBinary || !result.content || cacheGeneration !== readGeneration) { + // Why: local image paths must stay behind IPC/runtime authorization; + // handing raw file: or relative paths back to Chromium can escape it. + return null + } + const url = base64ToBlobUrl(result.content, result.mimeType ?? 'image/png') + if (cacheGeneration !== readGeneration) { + URL.revokeObjectURL(url) + return null + } + cacheBlobUrl(cacheKey, url) + return url + }) + .catch(() => null) + .finally(() => { + if (inFlightBlobUrlLoads.get(cacheKey) === loadPromise) { + inFlightBlobUrlLoads.delete(cacheKey) + } + }) + inFlightBlobUrlLoads.set(cacheKey, loadPromise) + return loadPromise +} + +export function resetLocalImageSrcStateForTests(): void { + if (pendingBlobUrlRevocationTimer !== null) { + clearTimeout(pendingBlobUrlRevocationTimer) + pendingBlobUrlRevocationTimer = null + } + revokePendingBlobUrls() + for (const url of blobUrlCache.values()) { + URL.revokeObjectURL(url) + } + blobUrlCache.clear() + inFlightBlobUrlLoads.clear() + cacheGeneration = 0 + pendingBlobUrlRevocations.clear() + cacheListeners.clear() +} + +export function invalidateLocalImageSrcCacheForTests(): void { + invalidateImageCache() } function readImagePreview( @@ -293,17 +337,21 @@ function readImagePreview( connectionId?: string | null, runtimeContext?: Omit & { connectionId?: string | null } ) { - if (!runtimeContext) { - return window.api.fs.readFile({ - filePath: absolutePath, - connectionId: connectionId ?? undefined - }) + try { + if (!runtimeContext) { + return window.api.fs.readFile({ + filePath: absolutePath, + connectionId: connectionId ?? undefined + }) + } + return readRuntimeFilePreview( + { + ...runtimeContext, + connectionId: runtimeContext.connectionId ?? connectionId ?? undefined + }, + absolutePath + ) + } catch (error) { + return Promise.reject(error) } - return readRuntimeFilePreview( - { - ...runtimeContext, - connectionId: runtimeContext.connectionId ?? connectionId ?? undefined - }, - absolutePath - ) } diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx new file mode 100644 index 00000000000..a4730f59e92 --- /dev/null +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.lazy-chunk.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { Suspense, act, type ReactElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary' + +const reportCrashMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: reportCrashMock +})) + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function createContainer(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + return { container, root: createRoot(container) } +} + +function BoundaryHarness({ children }: { children: ReactNode }): ReactElement { + return ( + + Loading...
}>{children} + + ) +} + +async function flushReactWork(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +describe('RecoverableRenderErrorBoundary lazy chunk containment', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let consoleError: ReturnType + + beforeEach(() => { + reportCrashMock.mockReset() + window.sessionStorage.clear() + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + window.sessionStorage.clear() + consoleError.mockRestore() + }) + + it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const LazyRejectingImport = lazyWithRetry( + () => + Promise.reject( + new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js') + ), + { retries: 0 } + ) + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + await flushReactWork() + await flushReactWork() + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + expect(reportCrashMock).not.toHaveBeenCalled() + }) + + it('still reports ordinary render errors', async () => { + const error = new Error('ordinary render failure') + function BrokenSurface(): ReactElement { + throw error + } + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + expect(reportCrashMock).toHaveBeenCalledTimes(1) + expect(reportCrashMock).toHaveBeenCalledWith( + expect.objectContaining({ + boundaryId: 'page.automations', + surface: 'page', + error + }) + ) + }) +}) diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx index afa7fbf3dc9..eddfbd14252 100644 --- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx @@ -2,6 +2,7 @@ import React from 'react' import { AlertTriangle, RotateCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { isLazyChunkLoadError } from '@/lib/lazy-with-retry' import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting' import type { ReactErrorBoundaryReportArgs } from '../../../../shared/crash-reporting' import { translate } from '@/i18n/i18n' @@ -48,6 +49,9 @@ export class RecoverableRenderErrorBoundary extends React.Component void | (() => void) @@ -32,6 +36,7 @@ type FloatingPanelStoreState = { activeGroupIdByWorktree: Record activeTabIdByWorktree: Record expandedPaneByTabId: Record + renamingTabId: string | null createTab: ( worktreeId: string, groupId?: string, @@ -57,6 +62,7 @@ type FloatingPanelStoreState = { activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void setTabCustomTitle: (tabId: string, title: string | null) => void + setRenamingTabId: (tabId: string | null) => void setTabColor: (tabId: string, color: string | null) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void makePreviewFilePermanent: (fileId: string, tabId?: string) => void @@ -65,7 +71,11 @@ type FloatingPanelStoreState = { browserDefaultUrl: string keybindings?: KeybindingOverrides tabBarOrderByWorktree: Record - settings: { activeRuntimeEnvironmentId?: string | null; floatingTerminalCwd?: string } + settings: { + activeRuntimeEnvironmentId?: string | null + floatingTerminalCwd?: string + terminalShortcutPolicy?: TerminalShortcutPolicy + } } const hookRuntime = vi.hoisted(() => ({ @@ -102,6 +112,7 @@ const mocks = vi.hoisted(() => ({ pickFloatingMarkdownDocument: vi.fn(), pinFile: vi.fn(), setActiveTab: vi.fn(), + setRenamingTabId: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -457,6 +468,7 @@ function resetStore(tabs: TerminalTab[] = []): void { activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, expandedPaneByTabId: {}, + renamingTabId: null, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, @@ -470,6 +482,7 @@ function resetStore(tabs: TerminalTab[] = []): void { pinFile: mocks.pinFile, setActiveTab: mocks.setActiveTab, setTabCustomTitle: mocks.setTabCustomTitle, + setRenamingTabId: mocks.setRenamingTabId, setTabColor: mocks.setTabColor, setTabPaneExpanded: mocks.setTabPaneExpanded, browserDefaultUrl: 'about:blank', @@ -593,10 +606,12 @@ function getPanelClassName(element: unknown): string { } function getMockedLocalStorage(): { + clear: ReturnType getItem: ReturnType setItem: ReturnType } { return window.localStorage as unknown as { + clear: ReturnType getItem: ReturnType setItem: ReturnType } @@ -637,6 +652,79 @@ function makeMacShortcutKeyEvent({ } } +function bindFocusedFloatingPanelKeydown(element: unknown): { + keydownListener: (event: unknown) => void + panelElement: { + contains: ReturnType + focus: ReturnType + closest: ReturnType + } +} { + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { + contains: vi.fn().mockReturnValue(true), + focus: vi.fn(), + closest: vi.fn() + } + panelElement.closest.mockImplementation((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + Object.setPrototypeOf(panelElement, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: panelElement, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + return { keydownListener, panelElement } +} + +function makeFocusedPanelKeyEvent({ + altKey = false, + code, + ctrlKey = false, + key, + metaKey = false, + preventDefault = vi.fn(), + shiftKey = false, + stopImmediatePropagation = vi.fn(), + stopPropagation = vi.fn(), + target +}: { + altKey?: boolean + code?: string + ctrlKey?: boolean + key: string + metaKey?: boolean + preventDefault?: () => void + shiftKey?: boolean + stopImmediatePropagation?: () => void + stopPropagation?: () => void + target: unknown +}): unknown { + return { + altKey, + code: code ?? (key.length === 1 && /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : key), + ctrlKey, + defaultPrevented: false, + key, + metaKey, + preventDefault, + repeat: false, + shiftKey, + stopImmediatePropagation, + stopPropagation, + target + } +} + describe('FloatingTerminalPanel close behavior', () => { beforeEach(() => { vi.clearAllMocks() @@ -646,6 +734,9 @@ describe('FloatingTerminalPanel close behavior', () => { hookRuntime.values = [] saveDialogBox.fileId = null resetStore() + // Why: the open-maximized intent is a module singleton; drain any leftover + // from a prior test so it cannot bleed into an unrelated render. + consumeFloatingTerminalOpenMaximizedIntent() mocks.createTab.mockReturnValue(makeTab({ id: 'created-tab' })) mocks.createWebRuntimeSessionBrowserTab.mockResolvedValue(false) mocks.createWebRuntimeSessionTerminal.mockResolvedValue(false) @@ -655,6 +746,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) const localStorage = { + clear: vi.fn(), getItem: vi.fn(() => null), removeItem: vi.fn(), setItem: vi.fn() @@ -1399,6 +1491,375 @@ describe('FloatingTerminalPanel close behavior', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-2') }) + it('routes focused floating tab rename shortcuts to the active floating tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.setRenamingTabId).toHaveBeenCalledWith('tab-1') + expect(mocks.setTabCustomTitle).not.toHaveBeenCalled() + }) + + it('routes focused floating tab index shortcuts to the matching visible tab', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' }), makeTab({ id: 'tab-3' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit3', + ctrlKey: true, + key: '3', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(stopPropagation).toHaveBeenCalledWith() + expect(stopImmediatePropagation).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('tab-3') + expect(mocks.setActiveTab).toHaveBeenCalledWith('tab-3') + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-3') + }) + + it('routes focused floating tab index shortcuts across mixed visible tab types', async () => { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const terminalTab = makeTab({ id: 'terminal-tab' }) + const simulatorTab: Tab = { + id: 'simulator-tab', + entityId: 'simulator-tab', + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'simulator', + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 1 + } + const browserTab: BrowserTab = { + id: 'browser-tab', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: '', + title: 'Browser', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 2 + } + const browserUnifiedTab: Tab = { + id: 'browser-unified-tab', + entityId: browserTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'browser', + label: 'Browser', + customLabel: null, + color: null, + sortOrder: 2, + createdAt: 2 + } + const terminalUnifiedTab: Tab = { + id: terminalTab.id, + entityId: terminalTab.id, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'terminal', + label: terminalTab.title, + customLabel: terminalTab.customTitle, + color: terminalTab.color, + sortOrder: 0, + createdAt: terminalTab.createdAt + } + state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [terminalTab] } + state.browserTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [browserTab] } + state.unifiedTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [terminalUnifiedTab, simulatorTab, browserUnifiedTab] + } + state.groupsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: terminalUnifiedTab.id, + tabOrder: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id], + recentTabIds: [terminalUnifiedTab.id, simulatorTab.id, browserUnifiedTab.id] + } + ] + } + state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: terminalTab.id } + state.tabBarOrderByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + terminalUnifiedTab.id, + simulatorTab.id, + browserUnifiedTab.id + ] + } + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(mocks.activateTab).toHaveBeenCalledWith('simulator-tab') + }) + + it('ignores focused floating tab index shortcuts past the visible tab count', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit5', + ctrlKey: true, + key: '5', + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(preventDefault).not.toHaveBeenCalled() + expect(stopPropagation).not.toHaveBeenCalled() + expect(stopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + expect(mocks.setActiveTab).not.toHaveBeenCalled() + }) + + it('ignores focused floating tab rename shortcuts when no tab is active', async () => { + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + const stopPropagation = vi.fn() + const stopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + key: 'r', + metaKey: true, + preventDefault, + stopImmediatePropagation, + stopPropagation, + target: panelElement + }) + ) + + expect(mocks.setRenamingTabId).not.toHaveBeenCalled() + }) + + it('leaves focused floating xterm tab index shortcuts to terminal-first terminals', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + ;(storeBox.state as FloatingPanelStoreState).settings = { + ...(storeBox.state as FloatingPanelStoreState).settings, + terminalShortcutPolicy: 'terminal-first' + } + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const target = { + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => + selector === '[data-floating-terminal-panel]' ? panelElement : null + ) + } + Object.setPrototypeOf(target, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + vi.stubGlobal('document', { + activeElement: target, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + runEffects() + const keydownListener = vi.mocked(window.addEventListener).mock.calls.find(([type]) => { + return type === 'keydown' + })?.[1] as ((event: unknown) => void) | undefined + if (!keydownListener) { + throw new Error('keydown listener not registered') + } + const ctrlPreventDefault = vi.fn() + const ctrlStopPropagation = vi.fn() + const ctrlStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + code: 'Digit2', + ctrlKey: true, + key: '2', + preventDefault: ctrlPreventDefault, + stopImmediatePropagation: ctrlStopImmediatePropagation, + stopPropagation: ctrlStopPropagation, + target + }) + ) + + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + const altPreventDefault = vi.fn() + const altStopPropagation = vi.fn() + const altStopImmediatePropagation = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + code: 'Digit2', + key: '2', + preventDefault: altPreventDefault, + stopImmediatePropagation: altStopImmediatePropagation, + stopPropagation: altStopPropagation, + target + }) + ) + + expect(ctrlPreventDefault).not.toHaveBeenCalled() + expect(ctrlStopPropagation).not.toHaveBeenCalled() + expect(ctrlStopImmediatePropagation).not.toHaveBeenCalled() + expect(altPreventDefault).not.toHaveBeenCalled() + expect(altStopPropagation).not.toHaveBeenCalled() + expect(altStopImmediatePropagation).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() + }) + + it('routes focused floating workspace maximize shortcuts like the titlebar control', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + + it('opens maximized when the open-maximized intent is set, ignoring saved bounds', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + requestFloatingTerminalOpenMaximized() + + await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + // Why: the intent is one-shot and must be consumed by the open transition. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('does not maximize on open when no intent is set', async () => { + const savedBounds = { left: 120, top: 96, width: 760, height: 420 } + getMockedLocalStorage().getItem.mockImplementation((key: string) => + key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null + ) + + const element = await renderPanel(true) + runEffects() + + expect(getPanelStyleBounds(element)).toEqual(savedBounds) + }) + + it('routes focused floating workspace minimize shortcuts to close the panel', async () => { + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.minimize': ['Ctrl+Alt+N'] + } as unknown as KeybindingOverrides + const onOpenChange = vi.fn() + const element = await renderPanel(true, onOpenChange) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'n', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('routes focused floating workspace maximize shortcuts from a custom binding on Linux', async () => { + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + ;(storeBox.state as FloatingPanelStoreState).keybindings = { + 'floatingWorkspace.maximize': ['Ctrl+Alt+M'] + } as unknown as KeybindingOverrides + const element = await renderPanel(true) + const { keydownListener, panelElement } = bindFocusedFloatingPanelKeydown(element) + const preventDefault = vi.fn() + + keydownListener( + makeFocusedPanelKeyEvent({ + altKey: true, + ctrlKey: true, + key: 'm', + preventDefault, + target: panelElement + }) + ) + + expect(preventDefault).toHaveBeenCalledWith() + expect(getPanelStyleBounds(await renderPanel(true))).toEqual( + getMaximizedFloatingTerminalBounds() + ) + }) + it('keeps the empty floating workspace focused after Cmd+W closes the last tab', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index f91eded8216..f7c4c31e9b8 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -58,6 +58,7 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { keybindingMatchesAction, + matchKeybindingDigitIndex, type KeybindingActionId, type KeybindingContext, type KeybindingMatchOptions, @@ -94,6 +95,7 @@ import { type FloatingTerminalPanelBoundsSource } from './floating-terminal-panel-bounds' import { translate } from '@/i18n/i18n' +import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal' const EMPTY_TERMINAL_TABS: TerminalTab[] = [] const EMPTY_BROWSER_TABS: BrowserTabState[] = [] const EMPTY_GROUPS: TabGroup[] = [] @@ -376,6 +378,26 @@ export function FloatingTerminalPanel({ }), [activeGroup, groupTabs] ) + const visibleFloatingTabOrder = useMemo( + () => + tabBarOrder.filter((visibleId) => { + const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) + if (!tab) { + return false + } + if (tab.contentType === 'terminal') { + return terminalItems.some((item) => item.unifiedTabId === tab.id) + } + if (tab.contentType === 'browser') { + return browserItems.some((item) => item.tabId === tab.id) + } + if (tab.contentType === 'simulator') { + return simulatorItems.some((item) => item.id === tab.id) + } + return editorItems.some((item) => item.tabId === tab.id) + }), + [browserItems, editorItems, groupTabs, simulatorItems, tabBarOrder, terminalItems] + ) const activeBrowserTab = activeBrowserId ? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null) : null @@ -963,21 +985,91 @@ export function FloatingTerminalPanel({ setFloatingTerminalInputFocusedInMain(isFloatingWorkspaceTerminalInputTarget(target)) }, []) + const toggleMaximized = useCallback(() => { + if (maximized) { + const restoredState = restoreBoundsRef.current ?? { + committedBounds: getDefaultFloatingTerminalCommittedBounds(), + renderedBounds: getDefaultFloatingTerminalBounds(), + source: 'default' as const + } + restoreBoundsRef.current = null + boundsSourceRef.current = restoredState.source + committedBoundsRef.current = restoredState.committedBounds + const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) + ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) + : restoredState.renderedBounds + stagedBoundsRef.current = null + setBounds(restoredBounds) + setMaximized(false) + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + const maximizePanel = useCallback(() => { + // Why: idempotent maximize used by the open-into-maximized intent. Unlike + // toggleMaximized it never restores, and only stashes restore bounds on the + // first transition so a redundant call cannot clobber the saved size. + if (maximized) { + return + } + restoreBoundsRef.current = { + committedBounds: committedBoundsRef.current, + renderedBounds: bounds, + source: boundsSourceRef.current + } + stagedBoundsRef.current = null + setBounds(getMaximizedFloatingTerminalBounds()) + setMaximized(true) + }, [bounds, maximized]) + + useEffect(() => { + // Why: when App opens the panel via Cmd+Opt+Shift+A while it was closed, + // it records a one-shot intent; honor it once the panel is open so it + // starts maximized regardless of its last saved size. + if (open && consumeFloatingTerminalOpenMaximizedIntent()) { + maximizePanel() + } + }, [open, maximizePanel]) + const handleFloatingPanelShortcutAction = useCallback( (input: FloatingPanelShortcutInput, consume: () => void): boolean => { const state = useAppStore.getState() const platform = getShortcutPlatform() + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(input.target) const context: KeybindingContext = input.doubleTapModifier ? 'app' - : isFloatingWorkspaceTerminalInputTarget(input.target) + : isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + // Floating panel chrome owns these controls even when xterm has DOM focus; + // keep the rest of the shortcut table in terminal context for terminal-first. + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const matches = (actionId: KeybindingActionId): boolean => keybindingMatchesAction(actionId, input, platform, state.keybindings, matchOptions) + const matchesFloatingChrome = (actionId: KeybindingActionId): boolean => + keybindingMatchesAction( + actionId, + input, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if (matches('tab.newTerminal')) { consume() @@ -1013,10 +1105,43 @@ export function FloatingTerminalPanel({ } return true } + if (matchesFloatingChrome('tab.rename') && activeTab) { + consume() + state.setRenamingTabId(activeTab.id) + return true + } + const selectedTabIndex = matchKeybindingDigitIndex( + 'tab.selectByIndex', + input, + platform, + state.keybindings, + matchOptions + ) + if (selectedTabIndex !== null) { + const visibleId = visibleFloatingTabOrder[selectedTabIndex] + if (!visibleId) { + return false + } + consume() + activateFloatingItem(visibleId) + return true + } + if (matchesFloatingChrome('floatingWorkspace.maximize')) { + consume() + toggleMaximized() + return true + } + if (matchesFloatingChrome('floatingWorkspace.minimize')) { + consume() + onOpenChange(false) + return true + } return false }, [ activeClosableTab, + activeTab, + activateFloatingItem, closeFloatingItem, createFloatingBrowserTab, createFloatingMarkdownTab, @@ -1024,7 +1149,9 @@ export function FloatingTerminalPanel({ focusPanelForShortcutsAfterClose, onOpenChange, openFloatingMarkdownTab, - visibleFloatingItemCount + toggleMaximized, + visibleFloatingItemCount, + visibleFloatingTabOrder ] ) @@ -1044,14 +1171,47 @@ export function FloatingTerminalPanel({ const state = useAppStore.getState() const platform = getShortcutPlatform() - const context: KeybindingContext = isFloatingWorkspaceTerminalInputTarget(event.target) - ? 'terminal' - : 'app' + const terminalShortcutPolicy = state.settings?.terminalShortcutPolicy + const isFloatingTerminalInput = isFloatingWorkspaceTerminalInputTarget(event.target) + const context: KeybindingContext = isFloatingTerminalInput ? 'terminal' : 'app' const matchOptions: KeybindingMatchOptions = { context, - terminalShortcutPolicy: state.settings?.terminalShortcutPolicy + terminalShortcutPolicy } + const floatingChromeMatchOptions: KeybindingMatchOptions = + isFloatingTerminalInput && terminalShortcutPolicy === 'terminal-first' + ? { context: 'app', terminalShortcutPolicy } + : matchOptions const nativeEvent = event.nativeEvent + const isFloatingChromeShortcut = + keybindingMatchesAction( + 'tab.rename', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + matchKeybindingDigitIndex( + 'tab.selectByIndex', + nativeEvent, + platform, + state.keybindings, + matchOptions + ) !== null || + keybindingMatchesAction( + 'floatingWorkspace.maximize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) || + keybindingMatchesAction( + 'floatingWorkspace.minimize', + nativeEvent, + platform, + state.keybindings, + floatingChromeMatchOptions + ) if ( !isFloatingWorkspacePanelShortcut( @@ -1060,7 +1220,8 @@ export function FloatingTerminalPanel({ panelRef.current, state.keybindings, matchOptions - ) + ) && + !isFloatingChromeShortcut ) { return } @@ -1245,35 +1406,6 @@ export function FloatingTerminalPanel({ window.removeEventListener('blur', handleWindowBlur) } }, [open]) - - const toggleMaximized = useCallback(() => { - if (maximized) { - const restoredState = restoreBoundsRef.current ?? { - committedBounds: getDefaultFloatingTerminalCommittedBounds(), - renderedBounds: getDefaultFloatingTerminalBounds(), - source: 'default' as const - } - restoreBoundsRef.current = null - boundsSourceRef.current = restoredState.source - committedBoundsRef.current = restoredState.committedBounds - const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source) - ? resolveFloatingTerminalPanelBounds(restoredState.committedBounds, restoredState.source) - : restoredState.renderedBounds - stagedBoundsRef.current = null - setBounds(restoredBounds) - setMaximized(false) - return - } - restoreBoundsRef.current = { - committedBounds: committedBoundsRef.current, - renderedBounds: bounds, - source: boundsSourceRef.current - } - stagedBoundsRef.current = null - setBounds(getMaximizedFloatingTerminalBounds()) - setMaximized(true) - }, [bounds, maximized]) - const handleDragStart = (event: React.PointerEvent): void => { if (maximized) { return diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 950378ae1d1..514402a0f36 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -16,6 +16,7 @@ import { resolveTuiAgentLaunchEnv } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -26,6 +27,14 @@ type FloatingTerminalWindowControlsProps = { const controlButtonClassName = 'border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground' +// Why: matches the repo convention (e.g. ReviewPRViewAnimatedVisual) of +// surfacing the live keybinding in a tooltip as "Label (shortcut)", while +// degrading to a bare label when the action is unbound (default on Win/Linux, +// and for minimize on every platform). +function withShortcutHint(label: string, shortcutLabel: string | null): string { + return shortcutLabel ? `${label} (${shortcutLabel})` : label +} + export function FloatingTerminalWindowControls({ maximized, onToggleMaximized, @@ -34,6 +43,8 @@ export function FloatingTerminalWindowControls({ const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) const createTab = useAppStore((s) => s.createTab) const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') + const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const defaultAgent = @@ -160,13 +171,19 @@ export function FloatingTerminalWindowControls({ {maximized - ? translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', - 'Restore' + ? withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.b5686fee1e', + 'Restore' + ), + maximizeShortcutLabel ) - : translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', - 'Maximize' + : withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.109870e023', + 'Maximize' + ), + maximizeShortcutLabel )} @@ -187,9 +204,12 @@ export function FloatingTerminalWindowControls({ - {translate( - 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', - 'Minimize' + {withShortcutHint( + translate( + 'auto.components.floating.terminal.FloatingTerminalWindowControls.2f6054342c', + 'Minimize' + ), + minimizeShortcutLabel )} diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index f76361d3654..705d306072c 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -47,19 +47,25 @@ import type { GitHubWorkItem } from '../../../../shared/types' import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker' import ProjectViewList from './ProjectViewList' import ProjectItemSlugDialog from './ProjectItemSlugDialog' -import { filterProjectTableRowsByOpenRepos } from './project-row-filtering' +import { + filterProjectTableRowsBySelectedRepos, + resolveSelectedProjectRowRepo +} from './project-row-filtering' import { resolveMissingRepoProjectDialogState, resolveRepoBackedProjectDialogState } from './project-dialog-state' import { + getSelectedRepoFingerprint, getNextVisibleProjectTableCache, getVisibleProjectTable, type CachedVisibleProjectTable } from './project-visible-table-cache' import { translate } from '@/i18n/i18n' -type Props = Record +type Props = { + selectedRepoIds: ReadonlySet +} const ORCA_FEATURE_REQUEST_URL = 'https://github.com/stablyai/orca/issues/new' @@ -80,7 +86,7 @@ function getProjectViewSourceScope(settings: Parameters s.settings) const projectViewCache = useAppStore((s) => s.projectViewCache) const fetchProjectViewTable = useAppStore((s) => s.fetchProjectViewTable) @@ -329,9 +335,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J const table: GitHubProjectTable | null = currentCacheKey ? (projectViewCache[currentCacheKey]?.data ?? null) : null + const selectedRepoFingerprint = useMemo( + () => getSelectedRepoFingerprint(selectedRepoIds), + [selectedRepoIds] + ) const filteredTable = useMemo( - () => (table && slugIndexReady ? filterProjectTableRowsByOpenRepos(table, lookupSlug) : null), - [table, slugIndexReady, lookupSlug] + () => + table && slugIndexReady + ? filterProjectTableRowsBySelectedRepos(table, lookupSlug, slugIndexReady, selectedRepoIds) + : null, + [table, slugIndexReady, lookupSlug, selectedRepoIds] ) const lastFilteredTableRef = useRef(null) // Why: this cache only prevents a blank table while the repo slug index @@ -339,6 +352,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J // a second render after every filtered-table change. lastFilteredTableRef.current = getNextVisibleProjectTableCache({ currentCacheKey, + selectedRepoFingerprint, sourceTable: table, slugIndexReady, filteredTable, @@ -346,6 +360,7 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J }) const visibleTable = getVisibleProjectTable({ currentCacheKey, + selectedRepoFingerprint, slugIndexReady, filteredTable, cachedTable: lastFilteredTableRef.current @@ -401,7 +416,11 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } | null>(null) const liveRepoIds = useMemo(() => new Set(repos.map((repo) => repo.id)), [repos]) - const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState(dialogRepoItem, liveRepoIds) + const resolvedDialogRepoItem = resolveRepoBackedProjectDialogState( + dialogRepoItem, + liveRepoIds, + selectedRepoIds + ) if (resolvedDialogRepoItem !== dialogRepoItem) { // Why: repo-backed Project dialogs cannot edit after their repo leaves // Orca; clear them before the modal tree receives stale repo ids. @@ -412,7 +431,8 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J slugIndexReady, slugDialog, repoNotInOrca, - lookupSlug + lookupSlug, + selectedRepoIds }) if (resolvedMissingRepoDialogs.slugDialog !== slugDialog) { // Why: once a previously missing repo is registered, Project rows should @@ -483,6 +503,13 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J [] ) + const openProjectRowUrlWithToast = useCallback((row: GitHubProjectRow, message: string) => { + if (row.content.url) { + void window.api.shell.openUrl(row.content.url) + } + toast.message(message) + }, []) + const handleOpenDialog = useCallback( (row: GitHubProjectRow) => { if (!currentCacheKey || !table) { @@ -496,19 +523,69 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } return } - const matches = lookupSlug(`${origin.owner}/${origin.repo}`) - const matched = matches.length === 1 ? matches[0] : null - if (matched) { - const workItem = buildWorkItem(row, matched.id) + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + if (resolution.status === 'loading') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.f352abf7c3', + 'Repository list is updating.' + ) + ) + return + } + if (resolution.status === 'selected_match') { + const workItem = buildWorkItem(row, resolution.repo.id) if (workItem) { - setDialogRepoItem({ workItem, repoPath: matched.path, repoId: matched.id, origin }) + setDialogRepoItem({ + workItem, + repoPath: resolution.repo.path, + repoId: resolution.repo.id, + origin + }) return } } - // Unknown repo — use the simplified slug-mode dialog. - setSlugDialog({ origin }) + if (resolution.status === 'no_global_match') { + // Unknown repo — use the simplified slug-mode dialog. + setSlugDialog({ origin }) + return + } + if (resolution.status === 'unselected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff', + 'This item is outside the selected repositories.' + ) + ) + return + } + if (resolution.status === 'ambiguous_selected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.030de75bc5', + 'This item matches multiple selected repositories.' + ) + ) + } }, - [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + [ + currentCacheKey, + table, + buildOrigin, + lookupSlug, + slugIndexReady, + selectedRepoIds, + openProjectRowUrlWithToast, + buildWorkItem + ] ) const handleStartWork = useCallback( @@ -520,9 +597,23 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J if (!origin) { return } - const matches = lookupSlug(`${origin.owner}/${origin.repo}`) - const matched = matches.length === 1 ? matches[0] : null - if (!matched) { + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + if (resolution.status === 'loading') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.f352abf7c3', + 'Repository list is updating.' + ) + ) + return + } + if (resolution.status === 'no_global_match') { setRepoNotInOrca({ owner: origin.owner, repo: origin.repo, @@ -530,13 +621,36 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J }) return } - const workItem = buildWorkItem(row, matched.id) + if (resolution.status === 'unselected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.1ce21b8cff', + 'This item is outside the selected repositories.' + ) + ) + return + } + if (resolution.status === 'ambiguous_selected_match') { + openProjectRowUrlWithToast( + row, + translate( + 'auto.components.github.project.ProjectViewWrapper.030de75bc5', + 'This item matches multiple selected repositories.' + ) + ) + return + } + if (resolution.status !== 'selected_match') { + return + } + const workItem = buildWorkItem(row, resolution.repo.id) if (!workItem) { return } void launchWorkItemDirect({ item: workItem, - repoId: matched.id, + repoId: resolution.repo.id, launchSource: 'task_page', telemetrySource: 'sidebar', openModalFallback: () => { @@ -550,7 +664,16 @@ export default function ProjectViewWrapper(_props: Props = {} as Props): React.J } }) }, - [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + [ + currentCacheKey, + table, + buildOrigin, + lookupSlug, + slugIndexReady, + selectedRepoIds, + openProjectRowUrlWithToast, + buildWorkItem + ] ) const handleEditAssignees = useCallback( diff --git a/src/renderer/src/components/github-project/project-dialog-state.test.ts b/src/renderer/src/components/github-project/project-dialog-state.test.ts index 78aa6b1274a..99afa238841 100644 --- a/src/renderer/src/components/github-project/project-dialog-state.test.ts +++ b/src/renderer/src/components/github-project/project-dialog-state.test.ts @@ -8,18 +8,34 @@ describe('resolveRepoBackedProjectDialogState', () => { it('keeps a repo-backed dialog when the repo still exists', () => { const dialog = { repoId: 'repo-1', label: 'Issue 1' } - expect(resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']))).toBe(dialog) + expect( + resolveRepoBackedProjectDialogState(dialog, new Set(['repo-1']), new Set(['repo-1'])) + ).toBe(dialog) }) it('clears a repo-backed dialog when its repo is removed', () => { expect( - resolveRepoBackedProjectDialogState({ repoId: 'repo-1' }, new Set(['repo-2'])) + resolveRepoBackedProjectDialogState( + { repoId: 'repo-1' }, + new Set(['repo-2']), + new Set(['repo-1']) + ) + ).toBeNull() + }) + + it('clears a repo-backed dialog when its repo is no longer selected', () => { + expect( + resolveRepoBackedProjectDialogState( + { repoId: 'repo-1' }, + new Set(['repo-1']), + new Set(['repo-2']) + ) ).toBeNull() }) }) describe('resolveMissingRepoProjectDialogState', () => { - it('waits for the slug index before closing missing-repo dialogs', () => { + it('clears fallback dialogs while the slug index is rebuilding', () => { const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } @@ -28,9 +44,10 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: false, slugDialog, repoNotInOrca, - lookupSlug: () => ['repo-1'] + lookupSlug: () => [{ id: 'repo-1' }], + selectedRepoIds: new Set(['repo-1']) }) - ).toEqual({ slugDialog, repoNotInOrca }) + ).toEqual({ slugDialog: null, repoNotInOrca: null }) }) it('clears slug fallback dialogs once the repo slug resolves', () => { @@ -40,7 +57,8 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: true, slugDialog, repoNotInOrca, - lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : []) + lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []), + selectedRepoIds: new Set(['repo-1']) }) expect(result.slugDialog).toBeNull() @@ -54,10 +72,39 @@ describe('resolveMissingRepoProjectDialogState', () => { slugIndexReady: true, slugDialog, repoNotInOrca, - lookupSlug: (slug) => (slug === 'stablyai/orca' ? ['repo-1'] : []) + lookupSlug: (slug) => (slug === 'stablyai/orca' ? [{ id: 'repo-1' }] : []), + selectedRepoIds: new Set(['repo-1']) }) expect(result.slugDialog).toBe(slugDialog) expect(result.repoNotInOrca).toBeNull() }) + + it('clears fallback dialogs when the repo is globally known but not selected', () => { + const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } + const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } + const result = resolveMissingRepoProjectDialogState({ + slugIndexReady: true, + slugDialog, + repoNotInOrca, + lookupSlug: () => [{ id: 'repo-2' }], + selectedRepoIds: new Set(['repo-1']) + }) + + expect(result).toEqual({ slugDialog: null, repoNotInOrca: null }) + }) + + it('keeps missing-repo fallback dialogs when there are no global matches', () => { + const slugDialog = { origin: { owner: 'stablyai', repo: 'orca' } } + const repoNotInOrca = { owner: 'stablyai', repo: 'orca', url: null } + const result = resolveMissingRepoProjectDialogState({ + slugIndexReady: true, + slugDialog, + repoNotInOrca, + lookupSlug: () => [], + selectedRepoIds: new Set(['repo-1']) + }) + + expect(result).toEqual({ slugDialog, repoNotInOrca }) + }) }) diff --git a/src/renderer/src/components/github-project/project-dialog-state.ts b/src/renderer/src/components/github-project/project-dialog-state.ts index cb092bcae10..88f71c9fcb1 100644 --- a/src/renderer/src/components/github-project/project-dialog-state.ts +++ b/src/renderer/src/components/github-project/project-dialog-state.ts @@ -14,17 +14,30 @@ type RepoNotInOrcaDialogState = { repo: string } -type LookupSlug = (slug: string) => readonly unknown[] +type RepoMatch = { + id: string +} -function hasRepoMatch(lookupSlug: LookupSlug, owner: string, repo: string): boolean { - return lookupSlug(`${owner}/${repo}`).length > 0 +type LookupSlug = (slug: string) => readonly RepoMatch[] + +function shouldCloseFallbackDialog(args: { + lookupSlug: LookupSlug + selectedRepoIds: ReadonlySet + owner: string + repo: string +}): boolean { + const matches = args.lookupSlug(`${args.owner}/${args.repo}`) + const selectedMatchCount = matches.filter((match) => args.selectedRepoIds.has(match.id)).length + const unselectedMatchCount = matches.length - selectedMatchCount + return selectedMatchCount > 0 || unselectedMatchCount > 0 } export function resolveRepoBackedProjectDialogState( dialog: T | null, - liveRepoIds: ReadonlySet + liveRepoIds: ReadonlySet, + selectedRepoIds: ReadonlySet ): T | null { - if (dialog && !liveRepoIds.has(dialog.repoId)) { + if (dialog && (!liveRepoIds.has(dialog.repoId) || !selectedRepoIds.has(dialog.repoId))) { return null } return dialog @@ -38,21 +51,34 @@ export function resolveMissingRepoProjectDialogState< slugDialog: TSlugDialog | null repoNotInOrca: TRepoNotInOrca | null lookupSlug: LookupSlug + selectedRepoIds: ReadonlySet }): { slugDialog: TSlugDialog | null repoNotInOrca: TRepoNotInOrca | null } { - const { lookupSlug, repoNotInOrca, slugDialog, slugIndexReady } = args + const { lookupSlug, repoNotInOrca, selectedRepoIds, slugDialog, slugIndexReady } = args if (!slugIndexReady) { - return { slugDialog, repoNotInOrca } + return { slugDialog: null, repoNotInOrca: null } } return { slugDialog: - slugDialog && hasRepoMatch(lookupSlug, slugDialog.origin.owner, slugDialog.origin.repo) + slugDialog && + shouldCloseFallbackDialog({ + lookupSlug, + selectedRepoIds, + owner: slugDialog.origin.owner, + repo: slugDialog.origin.repo + }) ? null : slugDialog, repoNotInOrca: - repoNotInOrca && hasRepoMatch(lookupSlug, repoNotInOrca.owner, repoNotInOrca.repo) + repoNotInOrca && + shouldCloseFallbackDialog({ + lookupSlug, + selectedRepoIds, + owner: repoNotInOrca.owner, + repo: repoNotInOrca.repo + }) ? null : repoNotInOrca } diff --git a/src/renderer/src/components/github-project/project-row-filtering.test.ts b/src/renderer/src/components/github-project/project-row-filtering.test.ts index fb061c262b9..f70b44be4a6 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.test.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { GitHubProjectRow, GitHubProjectTable } from '../../../../shared/github-project-types' import type { Repo } from '../../../../shared/types' -import { filterProjectTableRowsByOpenRepos } from './project-row-filtering' +import { + filterProjectTableRowsByOpenRepos, + filterProjectTableRowsBySelectedRepos, + resolveSelectedProjectRowRepo +} from './project-row-filtering' function repo(id: string): Repo { return { @@ -92,3 +96,113 @@ describe('filterProjectTableRowsByOpenRepos', () => { expect(filtered.totalCount).toBe(0) }) }) + +describe('filterProjectTableRowsBySelectedRepos', () => { + it('keeps a row when at least one slug match is selected', () => { + const rows = [row('visible', 'acme/orca'), row('hidden', 'acme/tool')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + (slug) => (slug?.toLowerCase() === 'acme/orca' ? [repo('repo-1')] : [repo('repo-2')]), + true, + new Set(['repo-1']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['visible']) + expect(filtered.totalCount).toBe(1) + }) + + it('filters a row when only unselected repos match', () => { + const rows = [row('hidden', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => [repo('repo-2')], + true, + new Set(['repo-1']) + ) + + expect(filtered.rows).toEqual([]) + expect(filtered.totalCount).toBe(0) + }) + + it('keeps a row with multiple selected matches for action ambiguity handling', () => { + const rows = [row('ambiguous', 'acme/orca')] + const filtered = filterProjectTableRowsBySelectedRepos( + table(rows), + () => [repo('repo-1'), repo('repo-2'), repo('repo-3')], + true, + new Set(['repo-1', 'repo-2']) + ) + + expect(filtered.rows.map((r) => r.id)).toEqual(['ambiguous']) + }) +}) + +describe('resolveSelectedProjectRowRepo', () => { + it('reports loading without reading stale slug matches', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('loading', 'acme/orca'), + lookupSlug: () => { + throw new Error('should not read stale matches') + }, + slugIndexReady: false, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('loading') + }) + + it('reports invalid slug for rows without a repository', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('missing-slug', null), + lookupSlug: () => [repo('repo-1')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('invalid_slug') + }) + + it('reports no global match when Orca has no repo for the slug', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('missing', 'acme/orca'), + lookupSlug: () => [], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('no_global_match') + }) + + it('reports global-only matches when the repo is not selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('unselected', 'acme/orca'), + lookupSlug: () => [repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1']) + }) + + expect(resolution.status).toBe('unselected_match') + }) + + it('returns the selected match when exactly one matching repo is selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('selected', 'acme/orca'), + lookupSlug: () => [repo('repo-1'), repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-2']) + }) + + expect(resolution).toMatchObject({ status: 'selected_match', repo: { id: 'repo-2' } }) + }) + + it('reports ambiguity when multiple matching repos are selected', () => { + const resolution = resolveSelectedProjectRowRepo({ + row: row('ambiguous', 'acme/orca'), + lookupSlug: () => [repo('repo-1'), repo('repo-2')], + slugIndexReady: true, + selectedRepoIds: new Set(['repo-1', 'repo-2']) + }) + + expect(resolution.status).toBe('ambiguous_selected_match') + }) +}) diff --git a/src/renderer/src/components/github-project/project-row-filtering.ts b/src/renderer/src/components/github-project/project-row-filtering.ts index 5e5f90d113c..a0c23c20d66 100644 --- a/src/renderer/src/components/github-project/project-row-filtering.ts +++ b/src/renderer/src/components/github-project/project-row-filtering.ts @@ -3,6 +3,52 @@ import type { Repo } from '../../../../shared/types' export type ProjectRowSlugLookup = (slug: string | null | undefined) => readonly Repo[] +export type SelectedProjectRowResolution = + | { status: 'loading' } + | { status: 'invalid_slug' } + | { status: 'no_global_match' } + | { status: 'unselected_match'; globalMatches: readonly Repo[] } + | { status: 'selected_match'; repo: Repo; globalMatches: readonly Repo[] } + | { + status: 'ambiguous_selected_match' + selectedMatches: readonly Repo[] + globalMatches: readonly Repo[] + } + +export function resolveSelectedProjectRowRepo(input: { + row: GitHubProjectRow + lookupSlug: ProjectRowSlugLookup + slugIndexReady: boolean + selectedRepoIds: ReadonlySet +}): SelectedProjectRowResolution { + if (!input.slugIndexReady) { + return { status: 'loading' } + } + + const repository = input.row.content.repository + if (!repository) { + return { status: 'invalid_slug' } + } + const [owner, repo] = repository.split('/') + if (!owner || !repo) { + return { status: 'invalid_slug' } + } + + const globalMatches = input.lookupSlug(repository) + if (globalMatches.length === 0) { + return { status: 'no_global_match' } + } + + const selectedMatches = globalMatches.filter((match) => input.selectedRepoIds.has(match.id)) + if (selectedMatches.length === 0) { + return { status: 'unselected_match', globalMatches } + } + if (selectedMatches.length === 1) { + return { status: 'selected_match', repo: selectedMatches[0], globalMatches } + } + return { status: 'ambiguous_selected_match', selectedMatches, globalMatches } +} + export function projectRowHasOpenRepo( row: GitHubProjectRow, lookupSlug: ProjectRowSlugLookup @@ -20,3 +66,26 @@ export function filterProjectTableRowsByOpenRepos( } return { ...table, rows, totalCount: rows.length } } + +export function filterProjectTableRowsBySelectedRepos( + table: GitHubProjectTable, + lookupSlug: ProjectRowSlugLookup, + slugIndexReady: boolean, + selectedRepoIds: ReadonlySet +): GitHubProjectTable { + const rows = table.rows.filter((row) => { + const resolution = resolveSelectedProjectRowRepo({ + row, + lookupSlug, + slugIndexReady, + selectedRepoIds + }) + return ( + resolution.status === 'selected_match' || resolution.status === 'ambiguous_selected_match' + ) + }) + if (rows.length === table.rows.length && table.totalCount === rows.length) { + return table + } + return { ...table, rows, totalCount: rows.length } +} diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts index e2bc44cca24..e5eeabc40fc 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.test.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import type { GitHubProjectTable } from '../../../../shared/github-project-types' import { + getSelectedRepoFingerprint, getNextVisibleProjectTableCache, - getVisibleProjectTable + getVisibleProjectTable, + getVisibleProjectTableCacheKey } from './project-visible-table-cache' function table(id: string): GitHubProjectTable { @@ -17,20 +19,22 @@ describe('project visible table cache', () => { expect( getNextVisibleProjectTableCache({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable, slugIndexReady: true, filteredTable, previous: null }) - ).toEqual({ cacheKey: 'project:view', table: filteredTable }) + ).toEqual({ cacheKey: 'project:view:selected:["repo-1"]', table: filteredTable }) }) it('keeps the previous cache while the slug index is rebuilding', () => { - const previous = { cacheKey: 'project:view', table: table('previous') } + const previous = { cacheKey: 'project:view:selected:["repo-1"]', table: table('previous') } expect( getNextVisibleProjectTableCache({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable: table('source'), slugIndexReady: false, filteredTable: null, @@ -45,6 +49,7 @@ describe('project visible table cache', () => { expect( getNextVisibleProjectTableCache({ currentCacheKey: null, + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), sourceTable: null, slugIndexReady: false, filteredTable: null, @@ -54,11 +59,12 @@ describe('project visible table cache', () => { }) it('shows a matching cached table while the slug index is rebuilding', () => { - const cachedTable = { cacheKey: 'project:view', table: table('cached') } + const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') } expect( getVisibleProjectTable({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), slugIndexReady: false, filteredTable: null, cachedTable @@ -67,15 +73,36 @@ describe('project visible table cache', () => { }) it('does not show stale cached data for a different cache key', () => { - const cachedTable = { cacheKey: 'other:view', table: table('cached') } + const cachedTable = { cacheKey: 'other:view:selected:["repo-1"]', table: table('cached') } expect( getVisibleProjectTable({ currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-1'])), slugIndexReady: false, filteredTable: null, cachedTable }) ).toBeNull() }) + + it('does not show stale cached data for a different repo selection', () => { + const cachedTable = { cacheKey: 'project:view:selected:["repo-1"]', table: table('cached') } + + expect( + getVisibleProjectTable({ + currentCacheKey: 'project:view', + selectedRepoFingerprint: getSelectedRepoFingerprint(new Set(['repo-2'])), + slugIndexReady: false, + filteredTable: null, + cachedTable + }) + ).toBeNull() + }) + + it('layers selection only onto the renderer visible-table cache key', () => { + const storeCacheKey = 'organization:stablyai:1:view-id:local' + + expect(getVisibleProjectTableCacheKey(storeCacheKey, '["repo-1"]')).not.toBe(storeCacheKey) + }) }) diff --git a/src/renderer/src/components/github-project/project-visible-table-cache.ts b/src/renderer/src/components/github-project/project-visible-table-cache.ts index 2411012259c..6390e897281 100644 --- a/src/renderer/src/components/github-project/project-visible-table-cache.ts +++ b/src/renderer/src/components/github-project/project-visible-table-cache.ts @@ -5,24 +5,41 @@ export type CachedVisibleProjectTable = { table: GitHubProjectTable } +export function getSelectedRepoFingerprint(selectedRepoIds: ReadonlySet): string { + return JSON.stringify([...selectedRepoIds].sort()) +} + +export function getVisibleProjectTableCacheKey( + currentCacheKey: string | null, + selectedRepoFingerprint: string +): string | null { + return currentCacheKey ? `${currentCacheKey}:selected:${selectedRepoFingerprint}` : null +} + export function getNextVisibleProjectTableCache(input: { currentCacheKey: string | null + selectedRepoFingerprint: string sourceTable: GitHubProjectTable | null slugIndexReady: boolean filteredTable: GitHubProjectTable | null previous: CachedVisibleProjectTable | null }): CachedVisibleProjectTable | null { - if (!input.currentCacheKey || !input.sourceTable) { + const visibleCacheKey = getVisibleProjectTableCacheKey( + input.currentCacheKey, + input.selectedRepoFingerprint + ) + if (!visibleCacheKey || !input.sourceTable) { return null } if (input.slugIndexReady && input.filteredTable) { - return { cacheKey: input.currentCacheKey, table: input.filteredTable } + return { cacheKey: visibleCacheKey, table: input.filteredTable } } return input.previous } export function getVisibleProjectTable(input: { currentCacheKey: string | null + selectedRepoFingerprint: string slugIndexReady: boolean filteredTable: GitHubProjectTable | null cachedTable: CachedVisibleProjectTable | null @@ -30,5 +47,9 @@ export function getVisibleProjectTable(input: { if (input.slugIndexReady || !input.currentCacheKey) { return input.filteredTable } - return input.cachedTable?.cacheKey === input.currentCacheKey ? input.cachedTable.table : null + const visibleCacheKey = getVisibleProjectTableCacheKey( + input.currentCacheKey, + input.selectedRepoFingerprint + ) + return input.cachedTable?.cacheKey === visibleCacheKey ? input.cachedTable.table : null } diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 43409635549..1d7ac73c013 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -121,7 +121,7 @@ export function HeroFlow({
s.settings?.agentCmdOverrides ?? {}) - const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const [query, setQuery] = useState('') - const [scope, setScope] = useState('workspace') + const [scope, setScope] = useState('project') const [sort, setSort] = useState('updated') - const [group, setGroup] = useState('folder') + const [group, setGroup] = useState('project') const [hideEmptySessions, setHideEmptySessions] = useState(true) const [agents, setAgents] = useState([...AI_VAULT_AGENTS]) const [sessions, setSessions] = useState([]) @@ -45,27 +60,61 @@ export default function AiVaultPanel(): React.JSX.Element { const refreshIdRef = useRef(0) const refreshInFlightRef = useRef(false) const mountedRef = useRef(true) + const userChangedScopeRef = useRef(false) - const isRemoteWorktree = Boolean(activeRepo?.connectionId) + const isRemoteWorktree = Boolean(activeWorktreeRepo?.connectionId) const activeWorktreePath = activeWorktree?.path ?? null // Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos. - const liveWorktrees = useMemo(() => Object.values(worktreesByRepo).flat(), [worktreesByRepo]) const activeWorktreePaths = useMemo( - () => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, liveWorktrees), - [activeWorktree, liveWorktrees] + () => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, allWorktrees), + [activeWorktree, allWorktrees] ) + const projectContext = useMemo( + () => + buildAiVaultProjectContext({ + repos, + worktrees: allWorktrees, + projectHostSetupProjection, + activeRepo, + activeWorktree, + sessions + }), + [activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions] + ) + const activeProjectKey = projectContext.activeProjectKey + const projectLabelByKey = projectContext.projectLabelByKey + const sessionProjectById = projectContext.sessionProjectById const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length const viewAdjustmentCount = (hasAllAgentsSelected ? 0 : 1) + (sort === 'updated' ? 0 : 1) + - (group === 'folder' ? 0 : 1) + + (group === 'project' ? 0 : 1) + (hideEmptySessions ? 0 : 1) + // Project scope depends on active project context, but should come back after + // transient context loss unless the user intentionally chose another scope. useEffect(() => { - if (!activeWorktreePath && scope === 'workspace') { - setScope('all') + const normalizedScope = normalizeAiVaultScopeForContext({ + scope, + activeProjectKey, + activeWorktreePath + }) + if (normalizedScope !== scope) { + setScope(normalizedScope) } - }, [activeWorktreePath, scope]) + }, [activeProjectKey, activeWorktreePath, scope]) + + useEffect(() => { + if ( + shouldRestoreAiVaultProjectScope({ + scope, + activeProjectKey, + userChangedScope: userChangedScopeRef.current + }) + ) { + setScope('project') + } + }, [activeProjectKey, scope]) const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => { if (refreshInFlightRef.current) { @@ -120,14 +169,32 @@ export default function AiVaultPanel(): React.JSX.Element { scope, sort, activeWorktreePaths, + activeProjectKey, + sessionProjectById, + projectLabelByKey, hideEmptySessions }), - [activeWorktreePaths, agents, hideEmptySessions, query, scope, sessions, sort] + [ + activeProjectKey, + activeWorktreePaths, + agents, + hideEmptySessions, + projectLabelByKey, + query, + scope, + sessionProjectById, + sessions, + sort + ] ) const groups = useMemo( - () => groupAiVaultSessions(filteredSessions, group), - [filteredSessions, group] + () => + groupAiVaultSessions(filteredSessions, group, { + sessionProjectById, + projectLabelByKey + }), + [filteredSessions, group, projectLabelByKey, sessionProjectById] ) const buildResumeCommand = useCallback( @@ -212,10 +279,15 @@ export default function AiVaultPanel(): React.JSX.Element { const resetViewOptions = useCallback(() => { setAgents([...AI_VAULT_AGENTS]) setSort('updated') - setGroup('folder') + setGroup('project') setHideEmptySessions(true) }, []) + const handleScopeChange = useCallback((nextScope: AiVaultScope) => { + userChangedScopeRef.current = nextScope !== 'project' + setScope(nextScope) + }, []) + const toggleGroup = useCallback((key: string) => { setCollapsedGroups((current) => { const next = new Set(current) @@ -237,6 +309,7 @@ export default function AiVaultPanel(): React.JSX.Element { sessionCount={sessions.length} hasScanResult={Boolean(scanResult)} activeWorktreePath={activeWorktreePath} + activeProjectKey={activeProjectKey} scope={scope} agents={agents} sort={sort} @@ -244,7 +317,7 @@ export default function AiVaultPanel(): React.JSX.Element { hideEmptySessions={hideEmptySessions} adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} - onScopeChange={setScope} + onScopeChange={handleScopeChange} onAgentEnabledChange={setAgentEnabled} onSortChange={setSort} onGroupChange={setGroup} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index a241117a458..f9feda064f4 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -6,7 +6,8 @@ import { Clock3, FolderOpen, ListFilter, - LoaderCircle + LoaderCircle, + PanelsTopLeft } from 'lucide-react' import { Button } from '@/components/ui/button' import { @@ -36,7 +37,7 @@ import { translate } from '@/i18n/i18n' const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0' const VAULT_SCOPE_TOGGLE_ITEM_CLASS = - 'h-6 min-h-6 min-w-0 border border-transparent bg-transparent px-1.5 text-[10px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1' + 'h-7 min-h-7 min-w-0 flex-1 basis-0 shrink border border-transparent bg-transparent px-2.5 text-[11px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1.5' export function VaultGroupHeader({ group, @@ -99,15 +100,21 @@ export function SessionLoadingState(): React.JSX.Element { export function VaultScopeSwitch({ scope, workspaceAvailable, + projectAvailable, onScopeChange }: { scope: AiVaultScope workspaceAvailable: boolean + projectAvailable: boolean onScopeChange: (scope: AiVaultScope) => void }): React.JSX.Element { - const worktreeLabel = translate( - 'auto.components.right.sidebar.AiVaultPanelControls.worktreeScope', - 'Worktree' + const workspaceLabel = translate( + 'auto.components.right.sidebar.AiVaultPanelControls.workspaceScope', + 'Workspace' + ) + const projectLabel = translate( + 'auto.components.right.sidebar.AiVaultPanelControls.projectScope', + 'Project' ) const allLabel = translate('auto.components.right.sidebar.AiVaultPanelControls.allScope', 'All') @@ -116,12 +123,12 @@ export function VaultScopeSwitch({ type="single" value={scope} onValueChange={(value) => { - if (value === 'workspace' || value === 'all') { + if (value === 'workspace' || value === 'project' || value === 'all') { onScopeChange(value) } }} variant="outline" - className="h-6 shrink-0 rounded-md border border-sidebar-border bg-sidebar-accent/35 shadow-xs" + className="h-7 w-full rounded-md border border-sidebar-border bg-sidebar-accent/35 shadow-xs" aria-label={translate( 'auto.components.right.sidebar.AiVaultPanelControls.scopeAriaLabel', 'Session History scope: {{value0}}', @@ -129,25 +136,37 @@ export function VaultScopeSwitch({ value0: scope === 'workspace' ? translate( - 'auto.components.right.sidebar.AiVaultPanelControls.currentWorktreeLower', - 'current worktree' - ) - : translate( - 'auto.components.right.sidebar.AiVaultPanelControls.allSessionsLower', - 'all sessions' + 'auto.components.right.sidebar.AiVaultPanelControls.currentWorkspaceLower', + 'current workspace' ) + : scope === 'project' + ? translate( + 'auto.components.right.sidebar.AiVaultPanelControls.currentProjectLower', + 'current project' + ) + : translate( + 'auto.components.right.sidebar.AiVaultPanelControls.allSessionsLower', + 'all sessions' + ) } )} > - - {allLabel} - - {worktreeLabel} + {workspaceLabel} + + + {projectLabel} + + + {allLabel} ) @@ -253,6 +272,10 @@ export function VaultViewMenu({ value={group} onValueChange={(value) => onGroupChange(value as AiVaultGroup)} > + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.project', 'Project')} + {translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 9c0af246830..309ecdbeae1 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -16,6 +16,7 @@ type AiVaultPanelHeaderProps = { sessionCount: number hasScanResult: boolean activeWorktreePath: string | null + activeProjectKey: string | null scope: AiVaultScope agents: readonly AiVaultAgent[] sort: AiVaultSort @@ -39,6 +40,7 @@ export function AiVaultPanelHeader({ sessionCount, hasScanResult, activeWorktreePath, + activeProjectKey, scope, agents, sort, @@ -56,7 +58,7 @@ export function AiVaultPanelHeader({ }: AiVaultPanelHeaderProps): React.JSX.Element { return (
-
+
{/* Why: below 300px the header competes with fixed controls, so compact copy prevents overlap. */} @@ -97,11 +99,6 @@ export function AiVaultPanelHeader({
-
+
+ +
+
({ store: { activeWorktreeId: null, activeWorkspaceKey: null, + settings: { + experimentalNewWorktreeCardStyle: true + }, folderWorkspaces: [], workspaceLineageByChildKey: {}, worktreeLineageById: {}, diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx index 690328db2a4..2a2a5a78588 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspaceWorktreesPanel.tsx @@ -16,6 +16,8 @@ function stopNestedWorktreeCardBubble(event: React.SyntheticEvent): export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey) + const experimentalNewWorktreeCardStyle = + useAppStore((s) => s.settings?.experimentalNewWorktreeCardStyle) === true const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const workspaceLineageByChildKey = useAppStore((s) => s.workspaceLineageByChildKey) const worktreeLineageById = useAppStore((s) => s.worktreeLineageById) @@ -58,9 +60,7 @@ export default function FolderWorkspaceWorktreesPanel(): React.JSX.Element { const safeLineageChildren = lineageChildren.filter((child) => !nextAncestorIds.has(child.id)) const hasSafeLineageChildren = safeLineageChildren.length > 0 const lineageGeometry = getLineageNestedRowGeometry({ - // Why: folder-workspace lineage always uses the in-card child geometry, - // regardless of the main sidebar experimental-card toggle. - experimentalNewWorktreeCardStyle: true, + experimentalNewWorktreeCardStyle, inheritedCardContentIndent: 0, lineageDepth: ancestorIds.size }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts b/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts index d503abbe213..50eab6dbbce 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControl.commit-drafts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { buildResolveConflictsPrompt, normalizeSourceControlViewMode, @@ -9,8 +9,16 @@ import { writeCommitDraftForWorktree } from './SourceControl' import { getNextSourceControlViewMode } from './source-control-header-toolbar' +import { + loadSessionCommitDrafts, + saveSessionCommitDrafts +} from '@/lib/source-control-commit-draft-session' describe('SourceControl commit drafts by worktree', () => { + afterEach(() => { + saveSessionCommitDrafts({}) + }) + it('returns an empty draft when the selected worktree has no message', () => { expect(readCommitDraftForWorktree({}, 'wt-a')).toBe('') }) @@ -28,6 +36,15 @@ describe('SourceControl commit drafts by worktree', () => { // than leaking the active worktree's text into all worktree views. expect(readCommitDraftForWorktree(drafts, 'wt-a')).toBe('feat: message for A') }) + + it('restores commit drafts after Source Control remounts in the same session', () => { + let drafts = loadSessionCommitDrafts() + + drafts = writeCommitDraftForWorktree(drafts, 'wt-a', 'feat: keep draft') + saveSessionCommitDrafts(drafts) + + expect(readCommitDraftForWorktree(loadSessionCommitDrafts(), 'wt-a')).toBe('feat: keep draft') + }) }) describe('SourceControl conflict resolution state', () => { diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 245555f2d2c..45da0012ddd 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -200,6 +200,10 @@ import type { SourceControlAiWriteTarget } from '../../../../shared/source-contr import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { + loadSessionCommitDrafts, + saveSessionCommitDrafts +} from '@/lib/source-control-commit-draft-session' import { getCommitFailureDialogWorktreeKey, shouldShowCommitFailureDialog, @@ -951,9 +955,11 @@ function SourceControlInner(): React.JSX.Element { // falsy until we have a real answer from the main process. const [defaultBaseRef, setDefaultBaseRef] = useState(null) const [filterQuery, setFilterQuery] = useState('') - // Why: commit drafts/errors are worktree-scoped during the mounted session, - // so switching worktrees restores each draft instead of wiping it. - const [commitDrafts, setCommitDrafts] = useState({}) + // Why: Source Control unmounts when the user switches tabs, so keep commit + // drafts in a module-scoped session cache and restore them on remount. + const [commitDrafts, setCommitDrafts] = useState(() => + loadSessionCommitDrafts() + ) const commitDraftsRef = useRef(commitDrafts) const [commitErrors, setCommitErrors] = useState>({}) const [remoteActionErrors, setRemoteActionErrors] = useState< @@ -1780,6 +1786,10 @@ function SourceControlInner(): React.JSX.Element { } }, [updateCommitDrafts, worktreeMap]) + useEffect(() => { + saveSessionCommitDrafts(commitDrafts) + }, [commitDrafts]) + useEffect(() => { // Why: users often finish merge/rebase conflicts in a terminal. Once git // status observes that operation end, the old Source Control failure banner diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts new file mode 100644 index 00000000000..72be0b95b12 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeAiVaultScopeForContext, + shouldRestoreAiVaultProjectScope +} from './ai-vault-scope-state' + +describe('normalizeAiVaultScopeForContext', () => { + it('falls back from project to all when no active project is available', () => { + expect( + normalizeAiVaultScopeForContext({ + scope: 'project', + activeProjectKey: null, + activeWorktreePath: '/repo' + }) + ).toBe('all') + }) + + it('falls back from workspace to all when no active workspace path is available', () => { + expect( + normalizeAiVaultScopeForContext({ + scope: 'workspace', + activeProjectKey: 'project:orca', + activeWorktreePath: null + }) + ).toBe('all') + }) + + it('keeps available project and workspace scopes selected', () => { + expect( + normalizeAiVaultScopeForContext({ + scope: 'project', + activeProjectKey: 'project:orca', + activeWorktreePath: '/repo' + }) + ).toBe('project') + + expect( + normalizeAiVaultScopeForContext({ + scope: 'workspace', + activeProjectKey: null, + activeWorktreePath: '/repo' + }) + ).toBe('workspace') + }) +}) + +describe('shouldRestoreAiVaultProjectScope', () => { + it('restores project after automatic fallback when a project becomes available', () => { + expect( + shouldRestoreAiVaultProjectScope({ + scope: 'all', + activeProjectKey: 'project:orca', + userChangedScope: false + }) + ).toBe(true) + }) + + it('does not restore project after the user manually changed scope', () => { + expect( + shouldRestoreAiVaultProjectScope({ + scope: 'all', + activeProjectKey: 'project:orca', + userChangedScope: true + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts new file mode 100644 index 00000000000..5f564cdcdcd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts @@ -0,0 +1,23 @@ +import type { AiVaultScope } from '../../../../shared/ai-vault-types' + +export function normalizeAiVaultScopeForContext(args: { + scope: AiVaultScope + activeProjectKey: string | null + activeWorktreePath: string | null +}): AiVaultScope { + if (args.scope === 'project' && !args.activeProjectKey) { + return 'all' + } + if (args.scope === 'workspace' && !args.activeWorktreePath) { + return 'all' + } + return args.scope +} + +export function shouldRestoreAiVaultProjectScope(args: { + scope: AiVaultScope + activeProjectKey: string | null + userChangedScope: boolean +}): boolean { + return Boolean(args.activeProjectKey && args.scope === 'all' && !args.userChangedScope) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 97772cac169..5acfd125b09 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -251,6 +251,62 @@ describe('filterAiVaultSessions', () => { }) ).toEqual([]) }) + + it('filters project scope by the resolved active project key', () => { + const projectSession = { ...baseSession, id: 'claude:project', cwd: '/repo/project' } + const otherSession = { ...baseSession, id: 'claude:other', cwd: '/repo/other' } + const sessionProjectById = new Map([ + [projectSession.id, { kind: 'repo' as const, key: 'project:orca', label: 'Orca' }], + [otherSession.id, { kind: 'repo' as const, key: 'project:other', label: 'Other' }] + ]) + + expect( + filterAiVaultSessions([projectSession, otherSession], { + query: '', + agents: ['claude'], + scope: 'project', + sort: 'updated', + activeWorktreePaths: [], + activeProjectKey: 'project:orca', + sessionProjectById, + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:project']) + }) + + it('does not show all sessions for project scope without an active project key', () => { + expect( + filterAiVaultSessions([baseSession], { + query: '', + agents: ['claude'], + scope: 'project', + sort: 'updated', + activeWorktreePaths: [], + activeProjectKey: null, + hideEmptySessions: true + }) + ).toEqual([]) + }) + + it('matches repo: queries against resolved project labels before folder fallback', () => { + const sessionProjectById = new Map([ + [baseSession.id, { kind: 'repo' as const, key: 'project:orca', label: 'Canonical Orca' }] + ]) + const projectLabelByKey = new Map([['project:orca', 'Canonical Orca']]) + + expect( + filterAiVaultSessions([baseSession], { + query: 'repo:canonical', + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + sessionProjectById, + projectLabelByKey, + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:1']) + }) }) describe('deriveAiVaultWorkspaceScopePaths', () => { @@ -384,6 +440,33 @@ describe('groupAiVaultSessions', () => { 'Codex' ]) }) + + it('groups sibling worktree sessions by project label when resolved', () => { + const sessions: AiVaultSession[] = [ + { ...baseSession, id: 'claude:1', cwd: '/repo/main' }, + { ...baseSession, id: 'codex:2', agent: 'codex', cwd: '/repo/worktree' } + ] + const sessionProjectById = new Map( + sessions.map((session) => [ + session.id, + { kind: 'repo' as const, key: 'project:orca', label: 'Orca' } + ]) + ) + const projectLabelByKey = new Map([['project:orca', 'Canonical Orca']]) + + expect( + groupAiVaultSessions(sessions, 'project', { + sessionProjectById, + projectLabelByKey + }) + ).toEqual([{ key: 'project:orca', label: 'Canonical Orca', sessions }]) + }) + + it('falls back to folder grouping when project metadata is unavailable', () => { + expect(groupAiVaultSessions([baseSession], 'project')).toEqual([ + { key: '/users/ada/repo/app', label: 'repo/app', sessions: [baseSession] } + ]) + }) }) describe('parseVaultQuery', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index 376e52a220f..f0422547761 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -17,6 +17,7 @@ import { aiVaultAgentLabel } from '../../../../shared/ai-vault-types' import type { Worktree } from '../../../../shared/types' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { sessionPreviewSearchText } from './ai-vault-session-display' +import type { AiVaultSessionProject } from './ai-vault-session-projects' export type AiVaultSessionFilterState = { query: string @@ -24,6 +25,9 @@ export type AiVaultSessionFilterState = { scope: AiVaultScope sort: AiVaultSort activeWorktreePaths: readonly string[] + activeProjectKey?: string | null + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap hideEmptySessions: boolean } @@ -78,7 +82,15 @@ export function filterAiVaultSessions( return false } } - return matchesQuery(session, parsedQuery) + if (filters.scope === 'project') { + if (!filters.activeProjectKey) { + return false + } + if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) { + return false + } + } + return matchesQuery(session, parsedQuery, filters) }) .sort((left, right) => compareSessions(left, right, filters.sort)) } @@ -110,13 +122,16 @@ export function deriveAiVaultWorkspaceScopePaths( export function groupAiVaultSessions( sessions: readonly AiVaultSession[], - group: AiVaultGroup + group: AiVaultGroup, + options: { + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + } = {} ): AiVaultSessionGroup[] { const groups = new Map() for (const session of sessions) { - const key = group === 'agent' ? session.agent : getFolderGroupKey(session.cwd) - const label = group === 'agent' ? agentLabel(session.agent) : folderLabel(session.cwd) + const { key, label } = getGroupIdentity(session, group, options) const existing = groups.get(key) if (existing) { existing.sessions.push(session) @@ -170,7 +185,11 @@ export function parseVaultQuery(query: string): ParsedQuery { return { terms, repoTerms, pathTerms } } -function matchesQuery(session: AiVaultSession, parsed: ParsedQuery): boolean { +function matchesQuery( + session: AiVaultSession, + parsed: ParsedQuery, + filters: Pick +): boolean { const searchable = [ session.title, session.sessionId, @@ -189,7 +208,12 @@ function matchesQuery(session: AiVaultSession, parsed: ParsedQuery): boolean { return false } - const repoLabel = folderLabel(session.cwd).toLowerCase() + const sessionProject = filters.sessionProjectById?.get(session.id) + const repoLabel = ( + sessionProject?.kind === 'repo' + ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) + : folderLabel(session.cwd) + ).toLowerCase() if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { return false } @@ -210,6 +234,32 @@ function compareSessions(left: AiVaultSession, right: AiVaultSession, sort: AiVa return rightTime - leftTime } +function getGroupIdentity( + session: AiVaultSession, + group: AiVaultGroup, + options: { + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + } +): Pick { + if (group === 'agent') { + return { key: session.agent, label: agentLabel(session.agent) } + } + if (group === 'project') { + const sessionProject = options.sessionProjectById?.get(session.id) + if (sessionProject) { + return { + key: sessionProject.key, + label: + options.projectLabelByKey?.get(sessionProject.key) || + sessionProject.label || + folderLabel(session.cwd) + } + } + } + return { key: getFolderGroupKey(session.cwd), label: folderLabel(session.cwd) } +} + function getFolderGroupKey(pathValue: string | null): string { return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown' } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts new file mode 100644 index 00000000000..fa53c685da8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -0,0 +1,506 @@ +import { describe, expect, it } from 'vitest' +import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-session-projects' + +const baseSession: AiVaultSession = { + id: 'claude:1', + agent: 'claude', + sessionId: 'session-1', + title: 'Implement project history', + cwd: '/Users/ada/orca', + branch: 'feature/history', + model: 'claude-sonnet-4-5', + filePath: '/Users/ada/.claude/projects/session-1.jsonl', + codexHome: null, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:10:00.000Z', + modifiedAt: '2026-05-01T10:10:00.000Z', + messageCount: 4, + totalTokens: 1200, + previewMessages: [], + resumeCommand: "cd '/Users/ada/orca' && claude --resume 'session-1'" +} + +describe('toAiVaultProjectKey', () => { + it('does not double-wrap compatibility repo project ids', () => { + expect(toAiVaultProjectKey('project-1', 'repo-1')).toBe('project:project-1') + expect(toAiVaultProjectKey('repo:repo-1', 'repo-1')).toBe('repo:repo-1') + expect(toAiVaultProjectKey(null, 'repo-1')).toBe('repo:repo-1') + }) +}) + +describe('buildAiVaultProjectContext', () => { + it('uses durable worktree project ids before repo fallback', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Legacy Repo', path: '/Users/ada/orca' }) + const project = makeProject({ id: 'project-1', displayName: 'Canonical Orca' }) + const worktree = makeWorktree({ + id: 'wt-1', + repoId: repo.id, + projectId: project.id, + path: '/Users/ada/orca' + }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [project], + setups: [makeSetup({ repoId: repo.id, projectId: project.id, path: repo.path })] + }), + activeRepo: repo, + activeWorktree: worktree, + sessions: [baseSession] + }) + + expect(context.activeProjectKey).toBe('project:project-1') + expect(context.sessionProjectById.get(baseSession.id)).toMatchObject({ + kind: 'repo', + key: 'project:project-1', + label: 'Canonical Orca' + }) + }) + + it('normalizes compatibility project ids to repo keys', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Orca', path: '/Users/ada/orca' }) + const worktree = makeWorktree({ + id: 'wt-1', + repoId: repo.id, + projectId: 'repo:repo-1', + path: '/Users/ada/orca' + }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'repo:repo-1', displayName: 'Compatibility Orca' })], + setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-1', path: repo.path })] + }), + activeRepo: repo, + activeWorktree: worktree, + sessions: [baseSession] + }) + + expect(context.activeProjectKey).toBe('repo:repo-1') + expect(context.sessionProjectById.get(baseSession.id)?.key).toBe('repo:repo-1') + expect(context.projectLabelByKey.get('repo:repo-1')).toBe('Orca') + }) + + it('falls back to repo ids for legacy records without project metadata', () => { + const repo = makeRepo({ id: 'repo-legacy', displayName: 'Legacy', path: '/repo/legacy' }) + const session = makeSession({ id: 'codex:legacy', cwd: '/repo/legacy/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ projects: [], setups: [] }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.activeProjectKey).toBe('repo:repo-legacy') + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'repo', + key: 'repo:repo-legacy', + label: 'Legacy' + }) + }) + + it('inherits setup project ids for legacy worktrees without project metadata', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Orca Repo', path: '/repo/orca' }) + const worktree = makeWorktree({ + id: 'wt-legacy', + repoId: repo.id, + path: '/repo/orca' + }) + const session = makeSession({ id: 'claude:legacy-worktree', cwd: '/repo/orca/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'github:stablyai/orca', displayName: 'Canonical Orca' })], + setups: [ + makeSetup({ + repoId: repo.id, + projectId: 'github:stablyai/orca', + path: repo.path + }) + ] + }), + activeRepo: repo, + activeWorktree: worktree, + sessions: [session] + }) + + expect(context.activeProjectKey).toBe('project:github:stablyai/orca') + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'repo', + key: 'project:github:stablyai/orca', + label: 'Canonical Orca' + }) + }) + + it('uses active worktree setup project ids when active repo is unavailable', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Orca Repo', path: '/repo/orca' }) + const worktree = makeWorktree({ + id: 'wt-restored', + repoId: repo.id, + path: '/repo/orca' + }) + const session = makeSession({ id: 'claude:restored', cwd: '/repo/orca/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'github:stablyai/orca', displayName: 'Canonical Orca' })], + setups: [ + makeSetup({ + repoId: repo.id, + projectId: 'github:stablyai/orca', + path: repo.path + }) + ] + }), + activeRepo: null, + activeWorktree: worktree, + sessions: [session] + }) + + expect(context.activeProjectKey).toBe('project:github:stablyai/orca') + expect(context.sessionProjectById.get(session.id)?.key).toBe('project:github:stablyai/orca') + }) + + it('inherits setup host ids for legacy worktrees without host metadata', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Runtime Repo', path: '/runtime/orca' }) + const worktree = makeWorktree({ + id: 'wt-runtime', + repoId: repo.id, + path: '/runtime/orca' + }) + const session = makeSession({ id: 'claude:runtime-worktree', cwd: '/runtime/orca/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'project-runtime', displayName: 'Runtime Project' })], + setups: [ + makeSetup({ + repoId: repo.id, + projectId: 'project-runtime', + path: repo.path, + hostId: 'runtime:preview' + }) + ] + }), + activeRepo: repo, + activeWorktree: worktree, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'repo', + key: 'project:project-runtime', + hostKey: 'runtime:preview' + }) + }) + + it('keeps project labels canonical instead of using first matched session order', () => { + const repoA = makeRepo({ id: 'repo-a', displayName: 'Fork Checkout', path: '/work/fork' }) + const repoB = makeRepo({ id: 'repo-b', displayName: 'Main Checkout', path: '/work/main' }) + const project = makeProject({ id: 'project-1', displayName: 'Canonical Project' }) + const firstSession = makeSession({ id: 'claude:first', cwd: '/work/fork/src' }) + const secondSession = makeSession({ id: 'codex:second', cwd: '/work/main/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repoA, repoB], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [project], + setups: [ + makeSetup({ repoId: repoA.id, projectId: project.id, path: repoA.path }), + makeSetup({ repoId: repoB.id, projectId: project.id, path: repoB.path }) + ] + }), + activeRepo: repoA, + activeWorktree: null, + sessions: [firstSession, secondSession] + }) + + expect(context.projectLabelByKey.get('project:project-1')).toBe('Canonical Project') + expect(context.sessionProjectById.get(firstSession.id)?.label).toBe('Canonical Project') + expect(context.sessionProjectById.get(secondSession.id)?.label).toBe('Canonical Project') + }) + + it('chooses the most specific nested path and lets worktrees win equal-length ties', () => { + const repo = makeRepo({ id: 'repo-root', displayName: 'Root', path: '/repo' }) + const childRepo = makeRepo({ id: 'repo-child', displayName: 'Child Repo', path: '/repo/pkg' }) + const worktree = makeWorktree({ + id: 'wt-child', + repoId: childRepo.id, + projectId: 'child-project', + path: '/repo/pkg' + }) + const session = makeSession({ id: 'claude:nested', cwd: '/repo/pkg/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo, childRepo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({ + projects: [makeProject({ id: 'child-project', displayName: 'Child Project' })], + setups: [ + makeSetup({ repoId: repo.id, projectId: 'root-project', path: repo.path }), + makeSetup({ repoId: childRepo.id, projectId: 'setup-child', path: childRepo.path }) + ] + }), + activeRepo: childRepo, + activeWorktree: worktree, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + key: 'project:child-project', + label: 'Child Project' + }) + }) + + it('matches Windows paths case-insensitively across separators', () => { + const repo = makeRepo({ + id: 'repo-win', + displayName: 'Windows Repo', + path: 'C:\\Users\\Ada\\Repo' + }) + const session = makeSession({ id: 'claude:win', cwd: 'c:/users/ada/repo/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-win', path: repo.path })] + }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)?.key).toBe('repo:repo-win') + }) + + it('falls back to folder when a hostless session matches multiple host buckets', () => { + const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) + const sshRepo = makeRepo({ + id: 'ssh', + displayName: 'SSH', + path: '/srv/orca', + connectionId: 'target-1' + }) + const session = makeSession({ id: 'claude:ambiguous', cwd: '/srv/orca/src' }) + + const context = buildAiVaultProjectContext({ + repos: [localRepo, sshRepo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [ + makeSetup({ repoId: localRepo.id, projectId: 'repo:local', path: localRepo.path }), + makeSetup({ + repoId: sshRepo.id, + projectId: 'repo:ssh', + path: sshRepo.path, + hostId: 'ssh:target-1', + connectionId: 'target-1' + }) + ] + }), + activeRepo: localRepo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'folder', + key: 'folder:/srv/orca/src', + label: 'orca/src' + }) + }) + + it('uses ProjectHostSetup host ids when detecting ambiguous host buckets', () => { + const localRepo = makeRepo({ id: 'local', displayName: 'Local', path: '/srv/orca' }) + const runtimeRepo = makeRepo({ id: 'runtime', displayName: 'Runtime', path: '/srv/orca' }) + const session = makeSession({ id: 'claude:runtime-ambiguous', cwd: '/srv/orca/src' }) + + const context = buildAiVaultProjectContext({ + repos: [localRepo, runtimeRepo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [ + makeSetup({ repoId: localRepo.id, projectId: 'repo:local', path: localRepo.path }), + makeSetup({ + repoId: runtimeRepo.id, + projectId: 'repo:runtime', + path: runtimeRepo.path, + hostId: 'runtime:preview' + }) + ] + }), + activeRepo: localRepo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'folder', + key: 'folder:/srv/orca/src', + label: 'orca/src' + }) + }) + + it('ignores blank setup paths instead of treating them as catch-all candidates', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' }) + const session = makeSession({ id: 'claude:outside', cwd: '/outside/path' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-1', path: '' })] + }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'folder', + key: 'folder:/outside/path', + label: 'outside/path' + }) + }) + + it('uses repo fallback candidates when a setup for the repo has a blank path', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Repo', path: '/repo' }) + const session = makeSession({ id: 'claude:inside-repo', cwd: '/repo/src' }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ + projects: [], + setups: [makeSetup({ repoId: repo.id, projectId: 'repo:repo-1', path: '' })] + }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toMatchObject({ + kind: 'repo', + key: 'repo:repo-1', + label: 'Repo' + }) + }) + + it('maps null cwd sessions to unknown', () => { + const repo = makeRepo({ id: 'repo-1', displayName: 'Orca', path: '/repo' }) + const session = makeSession({ id: 'claude:unknown', cwd: null }) + + const context = buildAiVaultProjectContext({ + repos: [repo], + worktrees: [], + projectHostSetupProjection: makeProjection({ projects: [], setups: [] }), + activeRepo: repo, + activeWorktree: null, + sessions: [session] + }) + + expect(context.sessionProjectById.get(session.id)).toEqual({ + kind: 'unknown', + key: 'unknown', + label: '' + }) + }) +}) + +function makeSession(overrides: Partial): AiVaultSession { + return { ...baseSession, ...overrides } +} + +function makeRepo(overrides: Partial): Repo { + return { + id: 'repo-1', + path: '/Users/ada/orca', + displayName: 'Orca', + badgeColor: '#737373', + addedAt: 1, + ...overrides + } +} + +function makeProject(overrides: Partial): Project { + return { + id: 'project-1', + displayName: 'Project', + badgeColor: '#737373', + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeSetup(overrides: Partial): ProjectHostSetup { + return { + id: overrides.repoId ?? 'setup-1', + projectId: 'project-1', + hostId: 'local', + repoId: 'repo-1', + path: '/Users/ada/orca', + displayName: 'Orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeWorktree(overrides: Partial): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + path: '/Users/ada/orca', + head: 'abc123', + branch: 'main', + isBare: false, + isMainWorktree: true, + ...overrides + } +} + +function makeProjection( + overrides: Partial +): ProjectHostSetupProjection { + return { + projects: [], + setups: [], + ...overrides + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts new file mode 100644 index 00000000000..fdb78325025 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -0,0 +1,284 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison, + normalizeRuntimePathSeparators +} from '../../../../shared/cross-platform-path' + +export type AiVaultSessionProject = { + kind: 'repo' | 'folder' | 'unknown' + key: string + label: string + projectId?: string + repoId?: string + hostKey?: string +} + +export type AiVaultProjectContext = { + activeProjectKey: string | null + activeRepoId: string | null + projectLabelByKey: Map + sessionProjectById: Map +} + +type SessionProjectCandidate = { + source: 'worktree' | 'setup' + normalizedPath: string + hostKey: string + projectId: string | null + repoId: string | null +} + +type ProjectResolverArgs = { + repos: readonly Repo[] + worktrees: readonly Worktree[] + projectHostSetupProjection: ProjectHostSetupProjection + activeRepo: Repo | null + activeWorktree: Worktree | null + sessions: readonly AiVaultSession[] +} + +export function buildAiVaultProjectContext({ + repos, + worktrees, + projectHostSetupProjection, + activeRepo, + activeWorktree, + sessions +}: ProjectResolverArgs): AiVaultProjectContext { + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) + const projectLabelByKey = buildProjectLabelByKey(repos, projectHostSetupProjection) + const candidates = buildProjectCandidates( + worktrees, + projectHostSetupProjection, + repoById, + setupByRepoId + ) + const sessionProjectById = new Map() + + for (const session of sessions) { + sessionProjectById.set( + session.id, + resolveSessionProject(session.cwd, candidates, projectLabelByKey) + ) + } + + return { + activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId), + activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null, + projectLabelByKey, + sessionProjectById + } +} + +export function toAiVaultProjectKey( + projectId: string | null | undefined, + repoId?: string | null +): string | null { + if (projectId) { + // Why: legacy projections can already use repo-prefixed project ids; wrapping + // them again would split active scope and resolved session keys. + return projectId.startsWith('repo:') ? projectId : `project:${projectId}` + } + return repoId ? `repo:${repoId}` : null +} + +function buildSetupByRepoId( + setups: readonly ProjectHostSetup[] +): ReadonlyMap { + const setupByRepoId = new Map() + for (const setup of setups) { + if (setup.repoId && !setupByRepoId.has(setup.repoId)) { + setupByRepoId.set(setup.repoId, setup) + } + } + return setupByRepoId +} + +function buildProjectLabelByKey( + repos: readonly Repo[], + projection: ProjectHostSetupProjection +): Map { + const labels = new Map() + const projectLabelById = new Map( + projection.projects.map((project) => [project.id, project.displayName]) + ) + + for (const project of projection.projects) { + const key = toAiVaultProjectKey(project.id, project.sourceRepoIds[0]) + if (key && !key.startsWith('repo:')) { + labels.set(key, project.displayName) + } + } + + for (const repo of repos) { + labels.set(`repo:${repo.id}`, repo.displayName) + } + + for (const setup of projection.setups) { + const key = toAiVaultProjectKey(setup.projectId, setup.repoId) + if (key && !labels.has(key)) { + labels.set(key, projectLabelById.get(setup.projectId) ?? setup.displayName) + } + } + + return labels +} + +function buildProjectCandidates( + worktrees: readonly Worktree[], + projection: ProjectHostSetupProjection, + repoById: ReadonlyMap, + setupByRepoId: ReadonlyMap +): SessionProjectCandidate[] { + const candidates: SessionProjectCandidate[] = [] + const setupRepoIds = new Set() + + for (const worktree of worktrees) { + if (!hasCandidatePath(worktree.path)) { + continue + } + const repo = repoById.get(worktree.repoId) + const setup = setupByRepoId.get(worktree.repoId) + candidates.push({ + source: 'worktree', + normalizedPath: normalizeRuntimePathForComparison(worktree.path), + hostKey: + worktree.hostId ?? + setup?.hostId ?? + (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID), + projectId: worktree.projectId ?? setup?.projectId ?? null, + repoId: worktree.repoId + }) + } + + for (const setup of projection.setups) { + if (setup.repoId) { + if (hasCandidatePath(setup.path)) { + setupRepoIds.add(setup.repoId) + } + } + if (!hasCandidatePath(setup.path)) { + continue + } + candidates.push({ + source: 'setup', + normalizedPath: normalizeRuntimePathForComparison(setup.path), + hostKey: setup.hostId || getRepoExecutionHostId(setup), + projectId: setup.projectId, + repoId: setup.repoId || null + }) + } + + for (const repo of repoById.values()) { + if (setupRepoIds.has(repo.id)) { + continue + } + if (!hasCandidatePath(repo.path)) { + continue + } + candidates.push({ + source: 'setup', + normalizedPath: normalizeRuntimePathForComparison(repo.path), + hostKey: getRepoExecutionHostId(repo), + projectId: null, + repoId: repo.id + }) + } + + return candidates +} + +function hasCandidatePath(pathValue: string): boolean { + return pathValue.trim().length > 0 +} + +function resolveSessionProject( + cwd: string | null, + candidates: readonly SessionProjectCandidate[], + projectLabelByKey: ReadonlyMap +): AiVaultSessionProject { + if (!cwd) { + return { kind: 'unknown', key: 'unknown', label: '' } + } + + const matches = candidates.filter((candidate) => + isPathInsideOrEqual(candidate.normalizedPath, cwd) + ) + const hostBuckets = new Set(matches.map((candidate) => candidate.hostKey)) + if (hostBuckets.size > 1) { + // Why: session rows do not carry host ids yet, so overlapping local/SSH + // paths must stay visible without being attributed to the wrong project. + return folderProject(cwd) + } + + const bestCandidate = matches.sort(compareCandidates)[0] + if (!bestCandidate) { + return folderProject(cwd) + } + + const key = toAiVaultProjectKey(bestCandidate.projectId, bestCandidate.repoId) + if (!key) { + return folderProject(cwd) + } + + return { + kind: 'repo', + key, + label: projectLabelByKey.get(key) ?? key, + ...(bestCandidate.projectId ? { projectId: bestCandidate.projectId } : {}), + ...(bestCandidate.repoId ? { repoId: bestCandidate.repoId } : {}), + hostKey: bestCandidate.hostKey + } +} + +function compareCandidates(left: SessionProjectCandidate, right: SessionProjectCandidate): number { + const lengthDifference = right.normalizedPath.length - left.normalizedPath.length + if (lengthDifference !== 0) { + return lengthDifference + } + if (left.source === right.source) { + return 0 + } + return left.source === 'worktree' ? -1 : 1 +} + +function folderProject(cwd: string): AiVaultSessionProject { + const normalizedPath = normalizeRuntimePathForComparison(cwd) + return { + kind: 'folder', + key: `folder:${normalizedPath}`, + label: compactFolderLabel(cwd) + } +} + +function compactFolderLabel(pathValue: string): string { + const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts[0] ?? pathValue +} + +function resolveActiveProjectKey( + activeRepo: Repo | null, + activeWorktree: Worktree | null, + setupByRepoId: ReadonlyMap +): string | null { + if (activeWorktree?.projectId) { + return toAiVaultProjectKey(activeWorktree.projectId, activeWorktree.repoId) + } + + const setup = + (activeRepo ? setupByRepoId.get(activeRepo.id) : null) ?? + (activeWorktree ? setupByRepoId.get(activeWorktree.repoId) : null) + if (setup) { + return toAiVaultProjectKey(setup.projectId, setup.repoId || activeRepo?.id) + } + + return toAiVaultProjectKey(null, activeRepo?.id ?? activeWorktree?.repoId ?? null) +} 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 1613f3c8b54..5fe28efa4be 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 @@ -1,19 +1,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { refreshGitStatusForWorktree, type GitStatusRefreshDeps } from './git-status-refresh' -import type { GitStatusResult } from '../../../../shared/types' +import { + clearGitStatusRefreshOrderingForTests, + refreshGitStatusForWorktree, + refreshGitStatusForWorktreeStrict, + type GitStatusRefreshDeps +} from './git-status-refresh' +import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/types' function makeDeps(): GitStatusRefreshDeps { return { setGitStatus: vi.fn(), updateWorktreeGitIdentity: vi.fn(), setUpstreamStatus: vi.fn(), - fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined) + fetchUpstreamStatus: vi.fn().mockResolvedValue(null) } } +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + describe('refreshGitStatusForWorktree', () => { beforeEach(() => { vi.unstubAllGlobals() + clearGitStatusRefreshOrderingForTests() }) it('stores status, branch identity, and upstream data from git status', async () => { @@ -77,7 +97,8 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, { - runtimeTargetSettings: undefined + runtimeTargetSettings: undefined, + applyUpstreamStatus: false }) }) @@ -106,7 +127,8 @@ describe('refreshGitStatusForWorktree', () => { }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, { - runtimeTargetSettings: undefined + runtimeTargetSettings: undefined, + applyUpstreamStatus: false }) }) @@ -133,6 +155,130 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setGitStatus).toHaveBeenCalledWith('wt-3', status) }) + it('bypasses automatic no-upstream backoff only for strict refreshes', async () => { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-normal', + worktreePath: '/repo', + deps + }) + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps + }) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + + it('does not let an older automatic upstream result overwrite a strict result', async () => { + const automaticStatus = deferred() + const strictStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + } + const staleAutomaticStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi + .fn() + .mockReturnValueOnce(automaticStatus.promise) + .mockResolvedValueOnce(strictStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + const automatic = refreshGitStatusForWorktree({ + worktreeId: 'wt-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-race', + worktreePath: '/repo', + deps + }) + automaticStatus.resolve(staleAutomaticStatus) + await automatic + + expect(deps.setGitStatus).toHaveBeenCalledTimes(1) + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-race', strictStatus) + expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-race', strictStatus.upstreamStatus) + }) + + it('does not let an older automatic explicit upstream fetch overwrite a strict result', async () => { + const automaticFetch = deferred() + const strictStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + } + const staleAutomaticUpstream: GitUpstreamStatus = { hasUpstream: false, ahead: 0, behind: 0 } + const gitStatus = vi + .fn() + .mockResolvedValueOnce({ + entries: [], + conflictOperation: 'unknown' + } satisfies GitStatusResult) + .mockResolvedValueOnce(strictStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + vi.mocked(deps.fetchUpstreamStatus).mockReturnValueOnce(automaticFetch.promise) + + const automatic = refreshGitStatusForWorktree({ + worktreeId: 'wt-fetch-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(deps.fetchUpstreamStatus).toHaveBeenCalledTimes(1)) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-fetch-race', + worktreePath: '/repo', + deps + }) + automaticFetch.resolve(staleAutomaticUpstream) + await automatic + + expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledWith( + 'wt-fetch-race', + strictStatus.upstreamStatus + ) + }) + it('clears stale branch identity when git status reports detached HEAD', async () => { const status: GitStatusResult = { entries: [], 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 89d4ec4f662..4c5b8fb7ace 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -18,8 +18,94 @@ export type GitStatusRefreshDeps = { worktreePath: string, connectionId?: string, pushTarget?: GitPushTarget, - options?: { runtimeTargetSettings?: Pick | null } - ) => Promise + options?: { + runtimeTargetSettings?: Pick | null + applyUpstreamStatus?: boolean + } + ) => Promise +} + +const MAX_REFRESH_ORDERING_WORKTREES = 1024 +const strictUpstreamRefreshGenerationByWorktree = new Map() +const automaticUpstreamRefreshInFlightByWorktree = new Map() + +function trimRefreshOrderingState(): void { + for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) { + if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) { + break + } + if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) { + continue + } + strictUpstreamRefreshGenerationByWorktree.delete(worktreeId) + } +} + +function beginAutomaticUpstreamRefresh(worktreeId: string): number { + automaticUpstreamRefreshInFlightByWorktree.set( + worktreeId, + (automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1 + ) + return strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0 +} + +function finishAutomaticUpstreamRefresh(worktreeId: string): void { + const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0 + if (count <= 1) { + automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId) + } else { + automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1) + } + trimRefreshOrderingState() +} + +function shouldApplyAutomaticUpstreamRefresh(worktreeId: string, startGeneration: number): boolean { + return (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === startGeneration +} + +async function fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration +}: { + settings?: Pick | null + worktreeId: string + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + deps: GitStatusRefreshDeps + startGeneration: number +}): Promise { + const upstreamStatus = await deps.fetchUpstreamStatus( + worktreeId, + worktreePath, + connectionId, + pushTarget, + { + runtimeTargetSettings: settings, + applyUpstreamStatus: false + } + ) + if (upstreamStatus && shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) { + deps.setUpstreamStatus(worktreeId, upstreamStatus) + } +} + +function beginStrictUpstreamRefresh(worktreeId: string): void { + strictUpstreamRefreshGenerationByWorktree.set( + worktreeId, + (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 + ) + trimRefreshOrderingState() +} + +export function clearGitStatusRefreshOrderingForTests(): void { + strictUpstreamRefreshGenerationByWorktree.clear() + automaticUpstreamRefreshInFlightByWorktree.clear() } export async function refreshGitStatusForWorktree({ @@ -37,51 +123,77 @@ export async function refreshGitStatusForWorktree({ pushTarget?: GitPushTarget deps: GitStatusRefreshDeps }): Promise { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + const upstreamStartGeneration = beginAutomaticUpstreamRefresh(worktreeId) + try { + const status = (await getRuntimeGitStatus({ + settings, + worktreeId, + worktreePath, + connectionId + })) as GitStatusResult - deps.setGitStatus(worktreeId, status) - // Why: branch switches can happen inside a terminal. `git status --branch` - // gives us the new identity without a separate worktree-list poll. - deps.updateWorktreeGitIdentity(worktreeId, { - head: status.head, - // Why: detached HEAD reports a head oid and no branch. Pass null as an - // explicit clear signal so stale branch names don't linger in the UI. - branch: status.branch ?? (status.head ? null : undefined) - }) - if (pushTarget) { - // 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, { - runtimeTargetSettings: settings + if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, upstreamStartGeneration)) { + return + } + + deps.setGitStatus(worktreeId, status) + // Why: branch switches can happen inside a terminal. `git status --branch` + // gives us the new identity without a separate worktree-list poll. + deps.updateWorktreeGitIdentity(worktreeId, { + head: status.head, + // Why: detached HEAD reports a head oid and no branch. Pass null as an + // explicit clear signal so stale branch names don't linger in the UI. + branch: status.branch ?? (status.head ? null : undefined) }) - return - } - if (status.upstreamStatus) { - if ( - status.upstreamStatus.ahead > 0 && - status.upstreamStatus.behind > 0 && - status.upstreamStatus.behindCommitsArePatchEquivalent === undefined - ) { - // 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, undefined, { - runtimeTargetSettings: settings + if (pushTarget) { + // 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 fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration: upstreamStartGeneration }) return } - deps.setUpstreamStatus(worktreeId, status.upstreamStatus) - return + if (status.upstreamStatus) { + if ( + status.upstreamStatus.ahead > 0 && + status.upstreamStatus.behind > 0 && + status.upstreamStatus.behindCommitsArePatchEquivalent === undefined + ) { + // 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 fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + deps, + startGeneration: upstreamStartGeneration + }) + return + } + deps.setUpstreamStatus(worktreeId, status.upstreamStatus) + return + } + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration: upstreamStartGeneration + }) + } finally { + finishAutomaticUpstreamRefresh(worktreeId) } - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { - runtimeTargetSettings: settings - }) } export async function refreshGitStatusForWorktreeStrict({ @@ -101,12 +213,20 @@ export async function refreshGitStatusForWorktreeStrict({ fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus'] } }): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + beginStrictUpstreamRefresh(worktreeId) + const status = (await getRuntimeGitStatus( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + { + // Why: strict refreshes are user-triggered reconciliation and must not reuse + // automatic polling's no-upstream backoff window. + bypassEffectiveUpstreamNegativeCache: true + } + )) as GitStatusResult deps.setGitStatus(worktreeId, status) // Why: branch switches can happen inside a terminal. `git status --branch` diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx new file mode 100644 index 00000000000..c9eb2fad92d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -0,0 +1,40 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { SourceControlBranchContextRow } from './source-control-branch-context-row' +import type { GitBranchCompareSummary } from '../../../../shared/types' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +const readySummary: GitBranchCompareSummary = { + baseRef: 'refs/remotes/origin/FRONT-192-ZisVoucherStrip', + baseOid: 'base', + compareRef: 'feature', + headOid: 'head', + mergeBase: 'base', + changedFiles: 0, + commitsAhead: 0, + status: 'ready' +} + +describe('SourceControlBranchContextRow', () => { + it('lets the base ref use the full available header width', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('refs/remotes/origin/FRONT-192-ZisVoucherStrip') + expect(markup).toContain('max-w-full') + expect(markup).toContain('min-w-0 flex-1') + expect(markup).not.toContain('max-w-[9rem]') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx index e2c51b1d81d..27d1183d5ac 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.tsx @@ -25,7 +25,7 @@ function BaseRefButton({ return (
) } @@ -102,11 +104,13 @@ export function SourceControlBranchContextRow({ if (summary.status !== 'ready') { return (
- + + + {summary.errorMessage ?? translate( @@ -131,15 +135,17 @@ export function SourceControlBranchContextRow({ return (
-
+
{translate('auto.components.right.sidebar.SourceControl.e8a1c4b203', 'vs')} - + + +
{stats.length > 0 ? ( diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 9f045022168..c534a7859f7 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -169,7 +169,8 @@ describe('useGitStatusPolling', () => { undefined, undefined, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + applyUpstreamStatus: false } ) }) @@ -192,7 +193,8 @@ describe('useGitStatusPolling', () => { undefined, pushTarget, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + applyUpstreamStatus: false } ) }) diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx index c2cab9dcee0..f7f43cff2ed 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx @@ -82,6 +82,35 @@ describe('FontAutocomplete', () => { ).toBe('JetBrains Mono') }) + it('shows the full list on focus when the committed font has multiple matching suggestions', async () => { + function Harness(): ReactNode { + const [value, setValue] = useState('Cascadia Mono') + return ( + + ) + } + + await act(async () => { + root.render() + }) + + await act(async () => { + getInput().focus() + }) + + expect(getOptionLabels()).toEqual([ + 'Arial', + 'Cascadia Code', + 'Cascadia Mono', + 'Cascadia Mono PL', + 'Consolas' + ]) + }) + it('keeps typed searches filtered even after the value updates', async () => { function Harness(): ReactNode { const [value, setValue] = useState('Geist') diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 6af9a0f9511..d60a72c6900 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -630,12 +630,10 @@ export function FontAutocomplete({ () => filterFontSuggestions(suggestions, query), [suggestions, query] ) - // Why: a committed font fills the input, but typed searches can also mirror - // into `value`; only expand exact matches outside an active search session. + // Why: the committed font fills the input, but opening the chooser should + // still reveal every installed font instead of only fonts sharing that name. const visibleSuggestions = - !isFilteringQuery && normalizedQuery === normalizedValue && filteredSuggestions.length <= 1 - ? suggestions - : filteredSuggestions + !isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions // Why: sync the highlighted index during render rather than via useEffect so // the correct item is highlighted on the very first paint after open/filter diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx index 3422b3fbeed..349bea46ea8 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.lifecycle.test.tsx @@ -9,8 +9,10 @@ type MockTerminalInstance = { open: Mock write: Mock reset: Mock + refresh: Mock dispose: Mock loadAddon: Mock + rows: number } type MockLigaturesAddonInstance = { @@ -66,8 +68,10 @@ vi.mock('@xterm/xterm', () => ({ open: Mock write: Mock reset: Mock + refresh: Mock dispose: Mock loadAddon: Mock + rows: number constructor(options: Record) { this.options = { ...options } @@ -78,7 +82,9 @@ vi.mock('@xterm/xterm', () => ({ }) this.write = vi.fn() this.reset = vi.fn() + this.refresh = vi.fn() this.dispose = vi.fn() + this.rows = Number(options.rows) this.loadAddon = vi.fn(() => { if (mockXterm.nextLoadAddonError) { throw mockXterm.nextLoadAddonError @@ -228,6 +234,7 @@ describe('TerminalSettingsPreview terminal lifecycle', () => { const addon = mockLigaturesAddon.instances[0] expect(terminal.loadAddon).toHaveBeenCalledOnce() expect(terminal.loadAddon).toHaveBeenCalledWith(addon) + expect(terminal.refresh).toHaveBeenCalledWith(0, 14) runCleanups() expect(addon.dispose).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx index 4294e497a93..8d2a74a51ca 100644 --- a/src/renderer/src/components/settings/TerminalSettingsPreview.tsx +++ b/src/renderer/src/components/settings/TerminalSettingsPreview.tsx @@ -260,6 +260,9 @@ export function TerminalSettingsPreview({ try { terminal.loadAddon(addon) ligaturesAddonRef.current = addon + // Why: the preview writes its sample before this effect runs; repaint + // so already-rendered operators switch to ligature glyphs immediately. + terminal.refresh(0, terminal.rows - 1) } catch (err) { addon.dispose() console.warn('[settings preview] ligatures addon failed to attach', err) diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 7d21c357afd..9927e4e9db2 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -13,10 +13,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { searchRepos } from '@/lib/repo-search' import { cn } from '@/lib/utils' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' @@ -37,6 +39,9 @@ const SidebarFilter = React.memo(function SidebarFilter({ }: SidebarFilterProps) { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) + // Surface the user-assigned shortcut here so the filter menu doubles as its + // discovery point ('Unassigned' until they bind one in Settings → Shortcuts). + const sleepingShortcut = useShortcutLabel('sidebar.sleepingWorkspaces.toggle') const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) @@ -183,6 +188,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ label={translate('auto.components.sidebar.SidebarFilter.638a2d221d', 'Hide sleeping')} checked={!showSleepingWorkspaces} onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} + shortcutLabel={sleepingShortcut === 'Unassigned' ? undefined : sleepingShortcut} /> } @@ -333,12 +339,14 @@ function FilterToggleRow({ icon, label, checked, - onChange + onChange, + shortcutLabel }: { icon: React.ReactNode label: string checked: boolean onChange: (next: boolean) => void + shortcutLabel?: string }) { return ( ) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 4a97f087f3f..b145b45e8ef 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -45,7 +45,10 @@ import { import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts' import { writeWorkspaceDragData } from './workspace-status' import { getWorktreeCardPrDisplay } from './worktree-card-pr-display' -import { getWorktreeCardTitleDisplay } from './worktree-card-title-display' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups' @@ -63,7 +66,10 @@ import { } from './workspace-delete-quick-action' import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' -import { getFlushWorktreeCardPaddingLeft } from './worktree-list-indentation' +import { + getFlushWorktreeCardPaddingLeft, + getNewCardStyleParentContentMarginLeft +} from './worktree-list-indentation' import { translate } from '@/i18n/i18n' import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -521,7 +527,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ issueTitle: issueDisplay?.title, reviewTitle: prDisplay?.title }) - const visibleCardTitle = newCardStyle ? cardTitleDisplay : worktree.displayName + const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName) + const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay const isDeleting = deleteState?.isDeleting ?? false const deleteModifierPressed = useWorkspaceDeleteModifierPressed() @@ -1173,11 +1180,16 @@ const WorktreeCard = React.memo(function WorktreeCard({ : undefined // Why: sidebar rows need a small surface inset, while their content remains // aligned with the pre-inset layout and the repo header hierarchy. + const applyNewCardStyleStatusLaneOffset = newCardStyle && showCombinedStatusSlot const cardPaddingLeft = flushSurface - ? getFlushWorktreeCardPaddingLeft(contentIndent) + ? getFlushWorktreeCardPaddingLeft(contentIndent, applyNewCardStyleStatusLaneOffset) : contentIndent > 0 ? `calc(0.125rem + ${contentIndent}px)` : null + const parentContentMarginLeft = + flushSurface && applyNewCardStyleStatusLaneOffset + ? getNewCardStyleParentContentMarginLeft(contentIndent) + : 0 const cardStyle = cardPaddingLeft ? { paddingLeft: cardPaddingLeft } : undefined const detailsAndPortsContent = hasDetails || hasPorts ? ( @@ -1241,6 +1253,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ 'flex w-full min-w-0 gap-0.5 pl-0', titleOnlyCard ? 'items-center' : 'items-start' )} + style={ + parentContentMarginLeft < 0 ? { marginLeft: `${parentContentMarginLeft}px` } : undefined + } data-worktree-card-parent-content="" > {showCombinedStatusSlot ? ( diff --git a/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx new file mode 100644 index 00000000000..76fa707c30b --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardIssueDetailSection.tsx @@ -0,0 +1,147 @@ +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { CircleDot, Copy, Ellipsis, ExternalLink, MonitorUp, Pencil } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { + WorktreeCardDetailSection, + WorktreeCardDetailSectionContent +} from './WorktreeCardDetailSection' +import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls' +import { IssueStateBadge } from './WorktreeCardMetadataStatusBadges' +import type { WorktreeCardIssueDisplay } from './worktree-card-meta-types' + +type WorktreeCardIssueDetailSectionProps = { + issue: WorktreeCardIssueDisplay | null + issueMenuOpen: boolean + onIssueMenuOpenChange: (open: boolean) => void + onCopyIssueLink?: () => void + onEditIssue?: (event: React.MouseEvent) => void + onOpenGitHubIssueInOrca?: (event: React.MouseEvent) => void +} + +export function WorktreeCardIssueDetailSection({ + issue, + issueMenuOpen, + onIssueMenuOpenChange, + onCopyIssueLink, + onEditIssue, + onOpenGitHubIssueInOrca +}: WorktreeCardIssueDetailSectionProps): React.JSX.Element | null { + if (!issue) { + return null + } + + const issueLabels = issue.labels ?? [] + const moreActionsLabel = translate( + 'auto.components.sidebar.WorktreeCardMeta.moreIssueActions', + 'More issue actions' + ) + const moreActionsTrigger = ( + + + + ) + + return ( + + } + label={translate( + 'auto.components.sidebar.WorktreeCardMeta.e97d8f2876', + 'Issue #{{value0}}', + { + value0: issue.number + } + )} + actions={ + <> + {issue.url && onCopyIssueLink && ( + + {issueMenuOpen ? ( + moreActionsTrigger + ) : ( + + {moreActionsTrigger} + + {moreActionsLabel} + + + )} + + + + {translate('auto.components.sidebar.WorktreeCardMeta.copyLink', 'Copy link')} + + + + )} + {onEditIssue && ( + + + + )} + {issue.url && onOpenGitHubIssueInOrca && ( + + + + )} + {issue.url && ( + + + + )} + + } + /> + +
+ {issue.title} +
+ {(issue.state || issueLabels.length > 0) && ( +
+ {issue.state && } + {issueLabels.map((label) => ( + + {label} + + ))} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx index 07c73ec5620..7464b6f5477 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.interaction.test.tsx @@ -2,9 +2,14 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WorktreeCardDetailsHover } from './WorktreeCardMeta' +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn() +})) + const interactionMocks = vi.hoisted(() => ({ hoverOpen: false, onHoverOpenChange: undefined as ((open: boolean) => void) | undefined, @@ -13,6 +18,10 @@ const interactionMocks = vi.hoisted(() => ({ onUnlinkSelect: undefined as (() => void) | undefined })) +vi.mock('sonner', () => ({ + toast: toastMocks +})) + vi.mock('@/components/ui/hover-card', () => ({ HoverCard: ({ children, @@ -78,6 +87,7 @@ const reviewFixture = { describe('WorktreeCardDetailsHover interactions', () => { let container: HTMLDivElement let root: Root + const writeClipboardText = vi.fn() afterEach(() => { act(() => { @@ -89,6 +99,21 @@ describe('WorktreeCardDetailsHover interactions', () => { interactionMocks.onHoverOpenChange = undefined interactionMocks.onReviewMenuOpenChange = undefined interactionMocks.onUnlinkSelect = undefined + writeClipboardText.mockReset() + toastMocks.success.mockReset() + toastMocks.error.mockReset() + }) + + beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { + writeClipboardText + } + } + }) + writeClipboardText.mockResolvedValue(undefined) }) function renderHover(onUnlinkReview = vi.fn()): ReturnType { @@ -142,16 +167,14 @@ describe('WorktreeCardDetailsHover interactions', () => { ) }) - it('suppresses the tooltip while the review menu is open', () => { + it('omits the review trigger tooltip while the review menu is open', () => { renderHover() act(() => { interactionMocks.onReviewMenuOpenChange?.(true) }) - expect(container.querySelector('[data-tooltip-open]')?.getAttribute('data-tooltip-open')).toBe( - 'false' - ) + expect(container.textContent).not.toContain('More PR actions') }) it('invokes unlink and closes the hover from the menu item', () => { @@ -178,4 +201,55 @@ describe('WorktreeCardDetailsHover interactions', () => { container.querySelector('[data-review-menu-open]')?.getAttribute('data-review-menu-open') ).toBe('false') }) + + it('copies the review URL and closes the hover from the menu item', async () => { + const onUnlinkReview = renderHover() + + act(() => { + interactionMocks.onHoverOpenChange?.(true) + interactionMocks.onReviewMenuOpenChange?.(true) + }) + + const copyButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Copy link') + ) + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456') + expect(onUnlinkReview).not.toHaveBeenCalled() + expect(toastMocks.success).toHaveBeenCalledWith('PR link copied') + expect(container.querySelector('[data-hover-open]')?.getAttribute('data-hover-open')).toBe( + 'false' + ) + expect( + container.querySelector('[data-review-menu-open]')?.getAttribute('data-review-menu-open') + ).toBe('false') + }) + + it('reports clipboard failures without unlinking the review', async () => { + writeClipboardText.mockRejectedValueOnce(new Error('clipboard unavailable')) + const onUnlinkReview = renderHover() + + act(() => { + interactionMocks.onHoverOpenChange?.(true) + interactionMocks.onReviewMenuOpenChange?.(true) + }) + + const copyButton = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Copy link') + ) + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + expect(writeClipboardText).toHaveBeenCalledWith('https://github.com/acme/orca/pull/456') + expect(onUnlinkReview).not.toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalledWith('Failed to copy link') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx index 54be63c528a..895efe1151a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.test.tsx @@ -109,13 +109,15 @@ describe('WorktreeCardDetailsHover', () => { expect(moreActionsIndex).toBeGreaterThan(-1) expect(markup).toContain('More PR actions') + expect(markup).toContain('Copy link') expect(markup).toContain('Unlink PR') expect(moreActionsIndex).toBeLessThan(openInOrcaIndex) expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex) expect(markup).not.toContain('aria-label="Unlink PR"') + expect(markup.indexOf('Copy link')).toBeLessThan(markup.indexOf('Unlink PR')) }) - it('puts issue edit before open actions and keeps GitHub last', () => { + it('puts issue copy menu before edit and open actions and keeps GitHub last', () => { const markup = renderToStaticMarkup( { ) + const moreActionsIndex = markup.indexOf('aria-label="More issue actions"') + const copyLinkIndex = markup.indexOf('Copy link') const editIssueIndex = markup.indexOf('aria-label="Edit issue"') const openInOrcaIndex = markup.indexOf('aria-label="Open in Orca"') const viewOnGitHubIndex = markup.indexOf('aria-label="View on GitHub"') + expect(moreActionsIndex).toBeGreaterThan(-1) + expect(copyLinkIndex).toBeGreaterThan(-1) expect(editIssueIndex).toBeGreaterThan(-1) + expect(moreActionsIndex).toBeLessThan(editIssueIndex) + expect(copyLinkIndex).toBeLessThan(editIssueIndex) expect(editIssueIndex).toBeLessThan(openInOrcaIndex) expect(openInOrcaIndex).toBeLessThan(viewOnGitHubIndex) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index 9025c86e6f6..a33e167669b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -2,6 +2,7 @@ import React from 'react' import { Badge } from '@/components/ui/badge' import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' import { CalendarClock, CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' +import { toast } from 'sonner' import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' @@ -12,7 +13,7 @@ import { WorktreeCardDetailSectionContent } from './WorktreeCardDetailSection' import { DetailHeader, MetaIconBadge, MetadataActionIcon } from './WorktreeCardMetadataControls' -import { IssueStateBadge, LinearStateBadge } from './WorktreeCardMetadataStatusBadges' +import { LinearStateBadge } from './WorktreeCardMetadataStatusBadges' import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' import { getReviewLabel, ReviewIcon } from './worktree-review-helpers' import type { @@ -25,6 +26,7 @@ import type { import { translate } from '@/i18n/i18n' import { WorktreeCardReviewDetailSection } from './WorktreeCardReviewDetailSection' import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection' +import { WorktreeCardIssueDetailSection } from './WorktreeCardIssueDetailSection' export type { WorktreeCardIssueDisplay, @@ -155,8 +157,10 @@ export function WorktreeCardDetailsHover({ const internalHoverControl = useWorktreeCardDetailsHoverControl() const { hoverOpen, + issueMenuOpen, reviewMenuOpen, handleHoverOpenChange, + handleIssueMenuOpenChange, handleReviewMenuOpenChange, closeHover } = hoverControl ?? internalHoverControl @@ -167,6 +171,43 @@ export function WorktreeCardDetailsHover({ }, [closeHover] ) + const copyLinkedWorkItemLink = React.useCallback(async (url: string, label: string) => { + try { + // Why: Electron clipboard IPC remains reliable from nested hover/dropdown + // overlays where browser clipboard activation can be lost. + await window.api.ui.writeClipboardText(url) + toast.success( + translate('auto.components.sidebar.WorktreeCardMeta.copyLinkSuccess', '{{value0}} copied', { + value0: label + }) + ) + } catch { + toast.error( + translate('auto.components.sidebar.WorktreeCardMeta.copyLinkFailure', 'Failed to copy link') + ) + } + }, []) + const handleCopyIssueLink = React.useCallback((): void => { + if (!issue?.url) { + return + } + closeHover() + void copyLinkedWorkItemLink( + issue.url, + translate('auto.components.sidebar.WorktreeCardMeta.issueLinkLabel', 'Issue link') + ) + }, [closeHover, copyLinkedWorkItemLink, issue?.url]) + const handleCopyReviewLink = React.useCallback((): void => { + if (!review?.url) { + return + } + void copyLinkedWorkItemLink( + review.url, + translate('auto.components.sidebar.WorktreeCardMeta.reviewLinkLabel', '{{value0}} link', { + value0: getReviewLabel(review) + }) + ) + }, [copyLinkedWorkItemLink, review]) const showIdentityHeader = Boolean(branchName || workspaceTitle) @@ -178,7 +219,6 @@ export function WorktreeCardDetailsHover({ return children } - const issueLabels = issue?.labels ?? [] const branchIdentity = branchName ? (
)} - {issue && ( - - } - label={translate( - 'auto.components.sidebar.WorktreeCardMeta.e97d8f2876', - 'Issue #{{value0}}', - { value0: issue.number } - )} - actions={ - <> - {onEditIssue && ( - - - - )} - {issue.url && onOpenGitHubIssueInOrca && ( - - - - )} - {issue.url && ( - - - - )} - - } - /> - -
- {issue.title} -
- {(issue.state || issueLabels.length > 0) && ( -
- {issue.state && } - {issueLabels.map((label) => ( - - {label} - - ))} -
- )} -
-
- )} + {linearIssue && ( @@ -357,6 +343,7 @@ export function WorktreeCardDetailsHover({ reviewMenuOpen={reviewMenuOpen} onReviewMenuOpenChange={handleReviewMenuOpenChange} onOpenReviewInOrca={onOpenReviewInOrca} + onCopyReviewLink={review?.url ? handleCopyReviewLink : undefined} onUnlinkReview={onUnlinkReview} closeHover={closeHover} /> diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx index 8b8f774fd34..257ccd70c70 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardReviewDetailSection.tsx @@ -7,7 +7,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Ellipsis, ExternalLink, MonitorUp, Unlink } from 'lucide-react' +import { Copy, Ellipsis, ExternalLink, MonitorUp, Unlink } from 'lucide-react' import { translate } from '@/i18n/i18n' import { WorktreeCardDetailSection, @@ -23,6 +23,7 @@ type WorktreeCardReviewDetailSectionProps = { reviewMenuOpen: boolean onReviewMenuOpenChange: (open: boolean) => void onOpenReviewInOrca?: (event: React.MouseEvent) => void + onCopyReviewLink?: () => void onUnlinkReview?: () => void closeHover: () => void } @@ -32,6 +33,7 @@ export function WorktreeCardReviewDetailSection({ reviewMenuOpen, onReviewMenuOpenChange, onOpenReviewInOrca, + onCopyReviewLink, onUnlinkReview, closeHover }: WorktreeCardReviewDetailSectionProps): React.JSX.Element | null { @@ -41,6 +43,25 @@ export function WorktreeCardReviewDetailSection({ const reviewLabel = getReviewLabel(review) const reviewProvider = getProviderName(review) + const moreActionsLabel = translate( + 'auto.components.sidebar.WorktreeCardMeta.dbe2d18972', + 'More {{value0}} actions', + { value0: reviewLabel } + ) + const moreActionsTrigger = ( + + + + ) const dismissAndOpenReview = (event: React.MouseEvent): void => { closeHover() onOpenReviewInOrca?.(event) @@ -57,53 +78,52 @@ export function WorktreeCardReviewDetailSection({ )} actions={ <> - {onUnlinkReview && ( + {(onCopyReviewLink || onUnlinkReview) && ( - - - - - - - - {translate( - 'auto.components.sidebar.WorktreeCardMeta.dbe2d18972', - 'More {{value0}} actions', - { value0: reviewLabel } - )} - - + {reviewMenuOpen ? ( + moreActionsTrigger + ) : ( + + {moreActionsTrigger} + + {moreActionsLabel} + + + )} - { - closeHover() - onUnlinkReview?.() - }} - > - - {translate( - 'auto.components.sidebar.WorktreeCardMeta.ae76907ca6', - 'Unlink {{value0}}', - { value0: reviewLabel } - )} - + {onCopyReviewLink && ( + { + closeHover() + onCopyReviewLink() + }} + > + + {translate( + 'auto.components.sidebar.WorktreeCardReviewDetailSection.copyLink', + 'Copy link' + )} + + )} + {onUnlinkReview && ( + { + closeHover() + onUnlinkReview() + }} + > + + {translate( + 'auto.components.sidebar.WorktreeCardMeta.ae76907ca6', + 'Unlink {{value0}}', + { value0: reviewLabel } + )} + + )} )} diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 60be4d41fde..1a82cccee05 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -78,6 +78,7 @@ import { ALL_GROUP_KEY, PINNED_GROUP_KEY, buildRows, + getProjectGroupHeaderKey, getGroupKeysForWorktree, getLineageGroupKey } from './worktree-list-groups' @@ -87,6 +88,7 @@ import { getActiveStickyIndexesForScroll, getStickyHeaderIndexes, getVirtualRowTransform, + pruneStaleVirtualRowElementCache, shouldUseHeaderTopSpacing, type RenderRow } from './worktree-list-virtual-rows' @@ -197,7 +199,7 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { getRepoHeaderCreateState } from './repo-header-create-state' -import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui' +import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui' import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets' import { keybindingMatchesAction } from '../../../../shared/keybindings' import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' @@ -437,6 +439,130 @@ function revealMountedWorktreeElement( return revealElementInScrollContainer(container, element, behavior) ? element : null } +function revealMountedSidebarRowElement( + container: HTMLElement, + rowKey: string, + behavior: ScrollBehavior +): HTMLElement | null { + const element = document.getElementById(getWorktreeOptionId(rowKey)) + if (!element || !container.contains(element)) { + return null + } + return revealElementInScrollContainer(container, element, behavior) ? element : null +} + +function getRenderRowSidebarKey(row: RenderRow): string | null { + if (row.type === 'header') { + return row.key + } + if (row.type === 'item') { + return row.rowKey + } + if (row.type === 'folder-workspace') { + return folderWorkspaceKey(row.folderWorkspace.id) + } + if (row.type === 'pending-creation') { + return `pending:${row.creationId}` + } + if (row.type === 'imported-worktrees-card') { + return row.key + } + return null +} + +function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean { + if (row.type === 'lineage-group') { + return row.rows.some((item) => item.rowKey === rowKey) + } + return getRenderRowSidebarKey(row) === rowKey +} + +function getProjectIdFromHeaderRowKey(rowKey: string): string | null { + if (!rowKey.startsWith('project:')) { + return null + } + const withoutPrefix = rowKey.slice('project:'.length) + const setupSeparator = withoutPrefix.indexOf('::setup:') + return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) +} + +function getRepoIdsFromHeaderRowKey( + rowKey: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): string[] { + if (rowKey.startsWith('repo:')) { + return [rowKey.slice('repo:'.length)] + } + const setupMarker = '::setup:' + const setupIndex = rowKey.indexOf(setupMarker) + if (rowKey.startsWith('project:') && setupIndex !== -1) { + return [rowKey.slice(setupIndex + setupMarker.length)] + } + const projectId = getProjectIdFromHeaderRowKey(rowKey) + if (!projectId) { + return [] + } + const repoIds = new Set() + for (const setup of projectGrouping?.projectHostSetups ?? []) { + if (setup.projectId === projectId && repoMap.has(setup.repoId)) { + repoIds.add(setup.repoId) + } + } + const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) + for (const repoId of project?.sourceRepoIds ?? []) { + if (repoMap.has(repoId)) { + repoIds.add(repoId) + } + } + return [...repoIds] +} + +function getProjectGroupAncestorKeys( + projectGroupId: string | null | undefined, + projectGroups: readonly ProjectGroup[] +): string[] { + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const keys: string[] = [] + const seen = new Set() + let currentGroupId = projectGroupId ?? null + while (currentGroupId && !seen.has(currentGroupId)) { + const group = groupsById.get(currentGroupId) + if (!group) { + break + } + seen.add(currentGroupId) + keys.unshift(getProjectGroupHeaderKey(group.id)) + currentGroupId = group.parentGroupId + } + return keys +} + +function getSidebarRowRevealAncestorKeys(args: { + rowKey: string + repoMap: Map + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel +}): string[] { + if (args.rowKey.startsWith('project-group:')) { + const groupId = args.rowKey.slice('project-group:'.length) + const group = args.projectGroups.find((candidate) => candidate.id === groupId) + return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) + } + const keys = new Set() + for (const repoId of getRepoIdsFromHeaderRowKey( + args.rowKey, + args.repoMap, + args.projectGrouping + )) { + const repo = args.repoMap.get(repoId) + for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { + keys.add(key) + } + } + return [...keys] +} + function getWorktreeVisibilityMenuLabel(repo: Repo): string { const visibility = effectiveExternalWorktreeVisibility( repo, @@ -469,7 +595,9 @@ type VirtualizedWorktreeViewportProps = { handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void activeModal: string pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void agentSendTargetWorktreeId: string | null worktrees: Worktree[] folderWorkspaces: readonly FolderWorkspace[] @@ -1095,7 +1223,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp handleCreateFolderWorkspace, activeModal, pendingRevealWorktree, + pendingRevealSidebarRow, clearPendingRevealWorktreeId, + clearPendingRevealSidebarRow, agentSendTargetWorktreeId, worktrees, folderWorkspaces, @@ -1141,9 +1271,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0) const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0) - const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState( - null - ) + const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) const worktreeDragSessionRef = useRef(null) @@ -1154,6 +1282,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const worktreeNativeAutoscrollLastFrameTimeRef = useRef(null) const worktreeNativeLatestPointRef = useRef(null) const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) + const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) const pendingRevealFrameIdsRef = useRef>(new Set()) const revealHighlightFrameIdRef = useRef(null) const revealHighlightTimeoutRef = useRef(null) @@ -1182,19 +1311,19 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp revealHighlightTimeoutRef.current = null } }, []) - const flashRevealedWorktree = useCallback( - (worktreeId: string) => { + const flashRevealedRow = useCallback( + (rowKey: string) => { clearRevealHighlightTimeout() clearRevealHighlightFrame() // Why: remove before add restarts the CSS glow when the user repeatedly // asks to reveal the same active workspace. - setHighlightedRevealWorktreeId(null) + setHighlightedRevealRowKey(null) revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => { revealHighlightFrameIdRef.current = null - setHighlightedRevealWorktreeId(worktreeId) + setHighlightedRevealRowKey(rowKey) revealHighlightTimeoutRef.current = window.setTimeout(() => { revealHighlightTimeoutRef.current = null - setHighlightedRevealWorktreeId(null) + setHighlightedRevealRowKey(null) }, 1500) }) }, @@ -1865,7 +1994,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp : null if (revealedOption) { if (pendingRevealWorktree.highlight) { - flashRevealedWorktree(pendingRevealWorktree.worktreeId) + const revealedRowKey = + revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow) + if (revealedRowKey) { + flashRevealedRow(revealedRowKey) + } } if (pendingRevealWorktree.beginRename) { setRenamingWorktreeId({ @@ -1928,18 +2061,142 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp settings, projectGroups, pendingRevealRetryTick, - flashRevealedWorktree, + flashRevealedRow, setRenamingWorktreeId, schedulePendingRevealFrame, cancelPendingRevealFrames ]) + React.useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + + const isProjectHeaderTarget = + pendingRevealSidebarRow.rowKey.startsWith('project-group:') || + pendingRevealSidebarRow.rowKey.startsWith('project:') || + pendingRevealSidebarRow.rowKey.startsWith('repo:') + if (isProjectHeaderTarget && groupBy !== 'repo') { + return + } + + let toggledAncestor = false + for (const groupKey of getSidebarRowRevealAncestorKeys({ + rowKey: pendingRevealSidebarRow.rowKey, + repoMap, + projectGroups, + projectGrouping + })) { + if (collapsedGroups.has(groupKey)) { + toggleGroup(groupKey) + toggledAncestor = true + } + } + if (toggledAncestor) { + return + } + + let cancelled = false + const retryPendingReveal = () => { + const previousRetry = pendingRowRevealRetryRef.current + const nextRetryCount = + previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1 + pendingRowRevealRetryRef.current = { + rowKey: pendingRevealSidebarRow.rowKey, + count: nextRetryCount + } + if (nextRetryCount <= 8) { + schedulePendingRevealFrame(() => { + if (!cancelled) { + setPendingRevealRetryTick((tick) => tick + 1) + } + }) + return true + } + return false + } + schedulePendingRevealFrame(() => { + if (cancelled) { + return + } + const targetIndex = renderRows.findIndex((row) => + rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey) + ) + if (targetIndex === -1) { + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + toast.error( + translate( + 'auto.components.sidebar.WorktreeList.sidebarRowMissing', + 'Target no longer exists' + ) + ) + return + } + + const retryExactRevealOnNextFrame = () => { + if (retryPendingReveal()) { + return + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + } + + const container = scrollRef.current + const revealedElement = container + ? revealMountedSidebarRowElement( + container, + pendingRevealSidebarRow.rowKey, + pendingRevealSidebarRow.behavior + ) + : null + if (revealedElement) { + if (pendingRevealSidebarRow.highlight) { + flashRevealedRow(pendingRevealSidebarRow.rowKey) + } + pendingRowRevealRetryRef.current = null + clearPendingRevealSidebarRow() + return + } + + virtualizer.scrollToIndex(targetIndex, { + align: 'auto', + behavior: 'auto' + }) + retryExactRevealOnNextFrame() + }) + + return () => { + cancelled = true + cancelPendingRevealFrames() + } + }, [ + pendingRevealSidebarRow, + repoMap, + projectGroups, + projectGrouping, + collapsedGroups, + groupBy, + toggleGroup, + renderRows, + virtualizer, + pendingRevealRetryTick, + flashRevealedRow, + clearPendingRevealSidebarRow, + schedulePendingRevealFrame, + cancelPendingRevealFrames + ]) + const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache)) const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache)) const renderRowKeySignature = useMemo( () => renderRows.map(getRenderRowKey).join('\n'), [renderRows] ) + const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows]) const totalSize = virtualizer.getTotalSize() const virtualItems = virtualizer.getVirtualItems() const activeStickyIndexes = getActiveStickyIndexesForScroll({ @@ -1975,6 +2232,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) useLayoutEffect(() => { + pruneStaleVirtualRowElementCache({ + activeRowKeys: activeRenderRowKeys, + virtualizer + }) // Why: after delete/collapse, TanStack may briefly retain the removed row's // cached element. Measuring that disconnected node reports 0px and corrupts // the next row's slot, so measure only elements whose DOM key still matches @@ -1982,7 +2243,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp measureMountedRows() const frameId = window.requestAnimationFrame(measureMountedRows) return () => window.cancelAnimationFrame(frameId) - }, [prCacheLen, issueCacheLen, measureMountedRows, renderRowKeySignature]) + }, [ + activeRenderRowKeys, + prCacheLen, + issueCacheLen, + measureMountedRows, + renderRowKeySignature, + virtualizer + ]) useVirtualizedScrollAnchor({ anchorRef: scrollAnchorRef, @@ -3728,6 +3996,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } >
s.activeWorktreeId) const currentSidebarWorktreeId = activeWorktreeId const groupBy = useAppStore((s) => s.groupBy) + const setGroupBy = useAppStore((s) => s.setGroupBy) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) @@ -4650,9 +4922,12 @@ const WorktreeList = React.memo(function WorktreeList({ const activeView = useAppStore((s) => s.activeView) const activeModal = useAppStore((s) => s.activeModal) const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree) + const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow) const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar) + const revealSidebarRow = useAppStore((s) => s.revealSidebarRow) const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal) const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId) + const clearPendingRevealSidebarRow = useAppStore((s) => s.clearPendingRevealSidebarRow) const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) // Why: agent-send eligibility only matters while the picker is open. When it // is closed, avoid subscribing WorktreeList to wake-time terminal layout churn. @@ -5312,6 +5587,23 @@ const WorktreeList = React.memo(function WorktreeList({ workspaceHostScope ] ) + const renderedSidebarRowKeys = useMemo(() => { + const keys = new Set() + for (const row of sectionRows) { + if (row.type === 'header') { + keys.add(row.key) + } else if (row.type === 'item') { + keys.add(row.rowKey) + } else if (row.type === 'folder-workspace') { + keys.add(folderWorkspaceKey(row.folderWorkspace.id)) + } else if (row.type === 'pending-creation') { + keys.add(`pending:${row.creationId}`) + } else if (row.type === 'imported-worktrees-card') { + keys.add(row.key) + } + } + return keys + }, [sectionRows]) // Why: status headers change during wake (inactive -> active). Key only on // the grouping mode so row identity survives those ordinary status moves. const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all' @@ -5849,14 +6141,16 @@ const WorktreeList = React.memo(function WorktreeList({ filterRepoIds, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, - visibleWorkspaceHostIds + visibleWorkspaceHostIds, + workspaceHostScope }), [ showSleepingWorkspaces, filterRepoIds, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, - visibleWorkspaceHostIds + visibleWorkspaceHostIds, + workspaceHostScope ] ) const hasFilters = sidebarHasActiveFilters(filterState) @@ -5894,12 +6188,48 @@ const WorktreeList = React.memo(function WorktreeList({ filterState ]) + useEffect(() => { + if (!pendingRevealSidebarRow) { + return + } + const rowKey = pendingRevealSidebarRow.rowKey + const isProjectHeaderTarget = + rowKey.startsWith('project-group:') || + rowKey.startsWith('project:') || + rowKey.startsWith('repo:') + if (isProjectHeaderTarget && groupBy !== 'repo') { + setGroupBy('repo') + return + } + if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) { + clearFilters() + } + }, [ + clearFilters, + groupBy, + hasFilters, + pendingRevealSidebarRow, + renderedSidebarRowKeys, + setGroupBy + ]) + const handleRevealCurrentWorkspaceRequest = useCallback( (event: Event) => { const detail = event instanceof CustomEvent ? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined) : undefined + if (detail?.target?.type === 'sidebar-row') { + const sidebarDetail = detail as Extract< + ScrollToCurrentWorkspaceRevealRequestDetail, + { target: { type: 'sidebar-row' } } + > + revealSidebarRow(detail.target.rowKey, { + behavior: 'smooth', + highlight: sidebarDetail.highlight !== false + }) + return + } if (!activeWorktreeId) { return } @@ -5919,13 +6249,14 @@ const WorktreeList = React.memo(function WorktreeList({ revealWorktreeInSidebar(activeWorktreeId, { behavior: 'smooth', highlight: true, - beginRename: detail?.beginRename === true + beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true }) }, [ activeWorktreeId, clearFilters, folderWorkspaces, + revealSidebarRow, renderedWorktreeIds, revealWorktreeInSidebar, worktreeMap @@ -6056,7 +6387,9 @@ const WorktreeList = React.memo(function WorktreeList({ handleCreateFolderWorkspace={handleCreateFolderWorkspace} activeModal={activeModal} pendingRevealWorktree={pendingRevealWorktree} + pendingRevealSidebarRow={pendingRevealSidebarRow} clearPendingRevealWorktreeId={clearPendingRevealWorktreeId} + clearPendingRevealSidebarRow={clearPendingRevealSidebarRow} agentSendTargetWorktreeId={agentSendTargetWorktreeId} worktrees={worktrees} folderWorkspaces={folderWorkspaces} diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index 1d3d26a77f9..14a51127c2e 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -5,6 +5,7 @@ import { } from '@/lib/new-workspace' import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' import { isOrcaCliAvailableForLaunch } from '@/lib/orca-cli-launch-availability' +import { createBrowserUuid } from '@/lib/browser-uuid' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan, @@ -223,6 +224,11 @@ export async function submitFolderWorkspaceCreate({ workspacePath: workspace.folderPath, connectionId: workspace.connectionId ?? projectGroup.connectionId }) + if (startupPlan && !startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this queued + // startup, so both halves share one renderer-session token. + startupPlan.launchToken = createBrowserUuid() + } const startup = quickAgent && startupPlan @@ -230,6 +236,7 @@ export async function submitFolderWorkspaceCreate({ command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, + ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: quickAgent, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } diff --git a/src/renderer/src/components/sidebar/project-header-color.test.ts b/src/renderer/src/components/sidebar/project-header-color.test.ts index e02d622e3b3..931319f242d 100644 --- a/src/renderer/src/components/sidebar/project-header-color.test.ts +++ b/src/renderer/src/components/sidebar/project-header-color.test.ts @@ -39,6 +39,16 @@ describe('resolveProjectGroupHeaderColor', () => { ).toBe(REPO_COLORS[5]) }) + it('returns the repo color for provider-backed project headers', () => { + expect( + resolveProjectGroupHeaderColor({ + groupBy: 'repo', + headerKey: 'project:github:stablyai/orca', + badgeColor: REPO_COLORS[6] + }) + ).toBe(REPO_COLORS[6]) + }) + it('falls back to gray for unknown project group headers', () => { expect( resolveProjectGroupHeaderColor({ diff --git a/src/renderer/src/components/sidebar/project-header-color.ts b/src/renderer/src/components/sidebar/project-header-color.ts index 8a83920bd11..c92f22bf555 100644 --- a/src/renderer/src/components/sidebar/project-header-color.ts +++ b/src/renderer/src/components/sidebar/project-header-color.ts @@ -2,6 +2,7 @@ import { DEFAULT_REPO_BADGE_COLOR, REPO_COLORS } from '../../../../shared/consta import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' const PROJECT_GROUP_HEADER_KEY_PREFIX = 'repo:' +const PROVIDER_PROJECT_HEADER_KEY_PREFIX = 'project:' export function resolveRepoHeaderColor(badgeColor: string | null | undefined): string { const normalizedBadgeColor = normalizeRepoBadgeColor(badgeColor) @@ -19,9 +20,13 @@ export function resolveProjectGroupHeaderColor(args: { headerKey: string badgeColor: string | null | undefined }): string | undefined { - // Why: pinned headers can appear while grouped by repo, but only repo:* headers - // represent a repo folder whose user-authored badge color should be shown. - if (args.groupBy !== 'repo' || !args.headerKey.startsWith(PROJECT_GROUP_HEADER_KEY_PREFIX)) { + // Why: pinned/project-folder headers can appear while grouped by repo; only + // actual project headers should inherit user-authored project colors. + if ( + args.groupBy !== 'repo' || + (!args.headerKey.startsWith(PROJECT_GROUP_HEADER_KEY_PREFIX) && + !args.headerKey.startsWith(PROVIDER_PROJECT_HEADER_KEY_PREFIX)) + ) { return undefined } return resolveRepoHeaderColor(args.badgeColor) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index 916e0adae39..8d54e0a7220 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -13,33 +13,13 @@ const mocks = vi.hoisted(() => { } }), tabsByWorktree: {} as Record, - ptyIdsByTabId: {} as Record, - browserTabsByWorktree: {} as Record, - openFiles: [] as { worktreeId: string }[] + ptyIdsByTabId: {} as Record } const activateAndRevealWorktree = vi.fn() - const markInputQuietSchedulerInput = vi.fn() - const pendingCallbacks: (() => void)[] = [] - const pendingCancels: ReturnType[] = [] - const scheduleAfterInputQuiet = vi.fn((callback: () => void) => { - let cancelled = false - const cancel = vi.fn(() => { - cancelled = true - }) - pendingCallbacks.push(() => { - if (!cancelled) { - callback() - } - }) - pendingCancels.push(cancel) - return cancel - }) + const activateAndRevealFolderWorkspace = vi.fn() return { + activateAndRevealFolderWorkspace, activateAndRevealWorktree, - markInputQuietSchedulerInput, - pendingCallbacks, - pendingCancels, - scheduleAfterInputQuiet, state, toastError: vi.fn() } @@ -52,26 +32,19 @@ vi.mock('@/store', () => ({ })) vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace, activateAndRevealWorktree: mocks.activateAndRevealWorktree })) -vi.mock('@/lib/input-quiet-scheduler', () => ({ - markInputQuietSchedulerInput: mocks.markInputQuietSchedulerInput, - scheduleAfterInputQuiet: mocks.scheduleAfterInputQuiet -})) - vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' import { runSleepWorktrees } from './sleep-worktree-flow' -describe('sleep flow vs queued slept-workspace activation', () => { +describe('sleep flow vs slept-workspace activation', () => { beforeEach(() => { mocks.activateAndRevealWorktree.mockClear() - mocks.markInputQuietSchedulerInput.mockClear() - mocks.scheduleAfterInputQuiet.mockClear() - mocks.pendingCallbacks.length = 0 - mocks.pendingCancels.length = 0 + mocks.activateAndRevealFolderWorkspace.mockClear() mocks.toastError.mockClear() mocks.state.activeWorktreeId = 'wt-parent' mocks.state.setActiveWorktree.mockClear() @@ -93,26 +66,22 @@ describe('sleep flow vs queued slept-workspace activation', () => { 'tab-child-2': ['pty-child-2'], 'tab-child-3': ['pty-child-3'] } - mocks.state.browserTabsByWorktree = {} - mocks.state.openFiles = [] }) - it('does not let an old slept-parent activation fire after sleeping children', async () => { + it('does not leave behind a delayed parent activation after sleeping children', async () => { await runSleepWorktrees(['wt-parent']) expect(mocks.state.activeWorktreeId).toBeNull() expect(mocks.state.ptyIdsByTabId['tab-parent']).toEqual([]) - // A normal click on the slept parent row during selection setup queues a - // wake internally, even though the user is just trying to select children. activateWorktreeFromSidebar('wt-parent') - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - expect(mocks.pendingCallbacks).toHaveLength(1) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-parent', { + revealInSidebar: false + }) await runSleepWorktrees(['wt-child-1', 'wt-child-2', 'wt-child-3']) - mocks.pendingCallbacks[0]?.() - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - expect(mocks.pendingCancels[0]).toHaveBeenCalledTimes(1) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) }) }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index c349dd6eba0..c60544949f1 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -14,9 +14,7 @@ const mocks = vi.hoisted(() => { const toastError = vi.fn() const markWorktreeSleepIntent = vi.fn() const clearWorktreeSleepIntent = vi.fn() - const cancelPendingSidebarWorktreeActivation = vi.fn() return { - cancelPendingSidebarWorktreeActivation, clearWorktreeSleepIntent, markWorktreeSleepIntent, state, @@ -35,9 +33,6 @@ vi.mock('@/lib/worktree-sleep-intent', () => ({ clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, markWorktreeSleepIntent: mocks.markWorktreeSleepIntent })) -vi.mock('@/lib/sidebar-worktree-activation', () => ({ - cancelPendingSidebarWorktreeActivation: mocks.cancelPendingSidebarWorktreeActivation -})) import { runSleepWorktree, runSleepWorktrees } from './sleep-worktree-flow' @@ -51,7 +46,6 @@ describe('runSleepWorktree', () => { mocks.state.consumeSuppressedPtyExit.mockClear() mocks.markWorktreeSleepIntent.mockClear() mocks.clearWorktreeSleepIntent.mockClear() - mocks.cancelPendingSidebarWorktreeActivation.mockClear() mocks.toastError.mockClear() mocks.state.activeWorktreeId = null mocks.state.tabsByWorktree = {} @@ -76,15 +70,6 @@ describe('runSleepWorktree', () => { expect(browsersCallOrder).toBeLessThan(terminalsCallOrder) }) - it('cancels a queued sidebar wake before sleeping worktrees', async () => { - await runSleepWorktrees(['wt-1', 'wt-2']) - - expect(mocks.cancelPendingSidebarWorktreeActivation).toHaveBeenCalledTimes(1) - const cancelCall = mocks.cancelPendingSidebarWorktreeActivation.mock.invocationCallOrder[0] - const browserCall = mocks.state.shutdownWorktreeBrowsers.mock.invocationCallOrder[0] - expect(cancelCall).toBeLessThan(browserCall) - }) - it('clears activeWorktreeId before teardown when the slept worktree is active', async () => { mocks.state.activeWorktreeId = 'wt-1' diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index c0a34d6ad90..76e653c0449 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -1,7 +1,6 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' -import { cancelPendingSidebarWorktreeActivation } from '@/lib/sidebar-worktree-activation' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' import { PINNED_GROUP_KEY } from './worktree-list-groups' @@ -106,9 +105,6 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise if (worktreeIds.length === 0) { return } - // Why: clicking a slept sidebar row queues a delayed wake. A later Sleep - // command is the newer intent, so do not let that stale wake respawn PTYs. - cancelPendingSidebarWorktreeActivation() const { activeWorktreeId, setActiveWorktree, diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 81a78bec138..25165df25b8 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -98,6 +98,7 @@ function filterState(overrides: Partial = {}): FilterState { filterRepoIds: [], hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + workspaceHostScope: 'all', ...overrides } } @@ -617,6 +618,16 @@ describe('computeClearFilterActions', () => { expect(actions.resetFilterRepoIds).toBe(true) }) + it('flags legacy single-host scope for reset even without visible host ids', () => { + expect(computeClearFilterActions(filterState({ workspaceHostScope: 'ssh:host-1' }))).toEqual({ + resetShowSleepingWorkspaces: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: false, + resetVisibleWorkspaceHostIds: true + }) + }) + it('flags every active filter simultaneously', () => { expect( computeClearFilterActions( diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 785fc2a39fa..434df0ee0de 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -36,6 +36,7 @@ export type SidebarFilterState = { hideDefaultBranchWorkspace: boolean hideAutomationGeneratedWorkspaces: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null + workspaceHostScope?: ExecutionHostScope } /** @@ -53,7 +54,8 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.filterRepoIds.length > 0 || state.hideDefaultBranchWorkspace || state.hideAutomationGeneratedWorkspaces || - state.visibleWorkspaceHostIds != null + state.visibleWorkspaceHostIds != null || + (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) ) } @@ -83,7 +85,9 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetFilterRepoIds: state.filterRepoIds.length > 0, resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, - resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null + resetVisibleWorkspaceHostIds: + state.visibleWorkspaceHostIds != null || + (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) } } diff --git a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts index b90b20d55eb..e17c12b7a26 100644 --- a/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts +++ b/src/renderer/src/components/sidebar/worktree-card-details-hover-state.ts @@ -1,32 +1,34 @@ import { useCallback, useRef, useState } from 'react' +type WorktreeCardDetailMenu = 'issue' | 'review' + export function useWorktreeCardDetailsHoverControl() { const [open, setOpen] = useState(false) - const [reviewMenuOpen, setReviewMenuOpen] = useState(false) + const [openMenu, setOpenMenu] = useState(null) const pendingHoverCloseRef = useRef(false) const closeHover = useCallback(() => { pendingHoverCloseRef.current = false - setReviewMenuOpen(false) + setOpenMenu(null) setOpen(false) }, []) const handleHoverOpenChange = useCallback( (next: boolean) => { - // Why: the portaled PR menu sits outside HoverCardContent — keep the card - // mounted until the menu closes so the unlink item stays clickable. - if (reviewMenuOpen) { + // Why: portaled detail menus sit outside HoverCardContent — keep the card + // mounted until the menu closes so the menu items stay clickable. + if (openMenu) { pendingHoverCloseRef.current = !next return } pendingHoverCloseRef.current = false setOpen(next) }, - [reviewMenuOpen] + [openMenu] ) - const handleReviewMenuOpenChange = useCallback((next: boolean) => { - setReviewMenuOpen(next) + const setDetailMenuOpen = useCallback((menu: WorktreeCardDetailMenu, next: boolean) => { + setOpenMenu(next ? menu : null) if (!next && pendingHoverCloseRef.current) { pendingHoverCloseRef.current = false setOpen(false) @@ -34,10 +36,12 @@ export function useWorktreeCardDetailsHoverControl() { }, []) return { - hoverOpen: open || reviewMenuOpen, - reviewMenuOpen, + hoverOpen: open || Boolean(openMenu), + issueMenuOpen: openMenu === 'issue', + reviewMenuOpen: openMenu === 'review', handleHoverOpenChange, - handleReviewMenuOpenChange, + handleIssueMenuOpenChange: (next: boolean) => setDetailMenuOpen('issue', next), + handleReviewMenuOpenChange: (next: boolean) => setDetailMenuOpen('review', next), closeHover } } diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts index 5e56d7df4ea..bb5cf7625c4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getWorktreeCardTitleDisplay } from './worktree-card-title-display' +import { + coerceWorktreeCardVisibleTitle, + getWorktreeCardTitleDisplay +} from './worktree-card-title-display' describe('worktree card title display', () => { it('keeps custom workspace titles', () => { @@ -10,6 +13,14 @@ describe('worktree card title display', () => { reviewTitle: 'Fix stale PR' }) ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' Custom workspace ', + branchName: 'feature/custom', + reviewTitle: 'Fix stale PR' + }) + ).toBe(' Custom workspace ') }) it('uses linked work titles instead of repeating the branch as the card title', () => { @@ -49,4 +60,63 @@ describe('worktree card title display', () => { }) ).toBe('test454545') }) + + it('uses linked work titles when the stored title is nullish and the branch is usable', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: undefined, + branchName: 'feature/local-branch', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Fix stale GH PR') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: 'feature/local-branch', + issueTitle: 'Fix stale issue' + }) + ).toBe('Fix stale issue') + }) + + it('treats blank stored titles as absent', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: ' ', + branchName: 'feature/local-branch' + }) + ).toBe('') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: '', + branchName: 'feature/local-branch', + linearIssueTitle: 'Fix stale Linear issue' + }) + ).toBe('Fix stale Linear issue') + }) + + it('skips linked-title replacement when the branch name is nullish or blank', () => { + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: 'Custom workspace', + branchName: undefined, + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('Custom workspace') + + expect( + getWorktreeCardTitleDisplay({ + storedDisplayName: null, + branchName: ' ', + reviewTitle: 'Fix stale GH PR' + }) + ).toBe('') + }) + + it('coerces legacy visible titles before downstream title operations', () => { + expect(coerceWorktreeCardVisibleTitle(undefined).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(null).trim()).toBe('') + expect(coerceWorktreeCardVisibleTitle(' Custom workspace ')).toBe(' Custom workspace ') + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-title-display.ts b/src/renderer/src/components/sidebar/worktree-card-title-display.ts index 5da84c1f5f4..be0aa9200d7 100644 --- a/src/renderer/src/components/sidebar/worktree-card-title-display.ts +++ b/src/renderer/src/components/sidebar/worktree-card-title-display.ts @@ -1,11 +1,16 @@ type WorktreeCardTitleDisplayInput = { - storedDisplayName: string - branchName: string + storedDisplayName: string | null | undefined + branchName: string | null | undefined linearIssueTitle?: string | null issueTitle?: string | null reviewTitle?: string | null } +function normalizeComparableTitle(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + function normalizeTitle(value: string | null | undefined): string | null { const trimmed = value?.trim() if (!trimmed) { @@ -17,8 +22,17 @@ function normalizeTitle(value: string | null | undefined): string | null { return trimmed } -function isBranchTitle(displayName: string, branchName: string): boolean { - return displayName.trim() === branchName.trim() +function isBranchTitle( + normalizedDisplayName: string | null, + normalizedBranchName: string | null +): boolean { + return normalizedDisplayName !== null && normalizedDisplayName === normalizedBranchName +} + +export function coerceWorktreeCardVisibleTitle(value: string | null | undefined): string { + // Why: the legacy card path can bypass title selection but still feeds trim() + // and inline rename props, so nullish persisted titles stop at this boundary. + return typeof value === 'string' ? value : '' } export function getWorktreeCardTitleDisplay({ @@ -28,8 +42,19 @@ export function getWorktreeCardTitleDisplay({ issueTitle, reviewTitle }: WorktreeCardTitleDisplayInput): string { - if (!branchName || !isBranchTitle(storedDisplayName, branchName)) { - return storedDisplayName + const normalizedStoredDisplayName = normalizeComparableTitle(storedDisplayName) + const normalizedBranchName = normalizeComparableTitle(branchName) + const visibleStoredDisplayName = coerceWorktreeCardVisibleTitle(storedDisplayName) + + if (!normalizedBranchName) { + return normalizedStoredDisplayName ? visibleStoredDisplayName : '' + } + + if ( + normalizedStoredDisplayName && + !isBranchTitle(normalizedStoredDisplayName, normalizedBranchName) + ) { + return visibleStoredDisplayName } // Why: branch names are available in hover/details; the closed card title @@ -38,6 +63,6 @@ export function getWorktreeCardTitleDisplay({ normalizeTitle(linearIssueTitle) ?? normalizeTitle(issueTitle) ?? normalizeTitle(reviewTitle) ?? - storedDisplayName + (normalizedStoredDisplayName ? visibleStoredDisplayName : '') ) } 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 cb667f6f1d5..8bf89d66a73 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -1365,6 +1365,89 @@ describe('project groups', () => { ]) }) + it('renders repos whose Project Group metadata is missing as top-level repo rows', () => { + 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 repoWithMissingGroup: Repo = { ...repo, projectGroupId: 'missing-group' } + + const rows = buildRows( + 'repo', + [worktree], + new Map([[repoWithMissingGroup.id, repoWithMissingGroup]]), + null, + new Set(), + new Map([[repoWithMissingGroup.id, 0]]), + undefined, + 'manual', + {}, + new Map([[worktree.id, worktree]]), + false, + undefined, + [group] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-1', + 'repo:repo-1' + ]) + expect(rows.find((row) => row.type === 'header' && row.key === 'repo:repo-1')).toMatchObject({ + projectGroupDepth: 0 + }) + }) + + it('does not render collapsed child-group repos as missing metadata fallbacks', () => { + const parentGroup: ProjectGroup = { + id: 'parent-group', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const childGroup: ProjectGroup = { + ...parentGroup, + id: 'child-group', + name: 'Services', + parentPath: '/platform/services', + parentGroupId: parentGroup.id + } + const repoInChildGroup: Repo = { ...repo, projectGroupId: childGroup.id } + + const rows = buildRows( + 'repo', + [worktree], + new Map([[repoInChildGroup.id, repoInChildGroup]]), + null, + new Set(['project-group:parent-group']), + new Map([[repoInChildGroup.id, 0]]), + undefined, + 'manual', + {}, + new Map([[worktree.id, worktree]]), + false, + undefined, + [parentGroup, childGroup] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:parent-group' + ]) + }) + it('disambiguates duplicate top-level repo basenames without renaming repos', () => { const group: ProjectGroup = { id: 'group-1', @@ -2106,12 +2189,60 @@ describe('project groups', () => { it('returns both parent Project Group and repo keys for grouped repo reveals', () => { const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + 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 + } expect( - getGroupKeysForWorktree('repo', worktree, new Map([[groupedRepo.id, groupedRepo]]), null) + getGroupKeysForWorktree( + 'repo', + worktree, + new Map([[groupedRepo.id, groupedRepo]]), + null, + undefined, + undefined, + [group] + ) ).toEqual(['project-group:group-1', 'repo:repo-1']) }) + it('returns only the repo key for missing Project Group metadata reveals', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'missing-group' } + const loadedGroup: ProjectGroup = { + id: 'loaded-group', + name: 'Loaded', + parentPath: '/loaded', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + + expect( + getGroupKeysForWorktree( + 'repo', + worktree, + new Map([[groupedRepo.id, groupedRepo]]), + null, + undefined, + undefined, + [loadedGroup] + ) + ).toEqual(['repo:repo-1']) + }) + it('returns only the repo key for ungrouped repo reveals', () => { expect(getGroupKeysForWorktree('repo', worktree, repoMap, null)).toEqual(['repo:repo-1']) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 190ef5bcebc..91f9aa5c8b3 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -145,14 +145,24 @@ type ProjectGroupingIndex = { multiSetupProjectHostKeys: Set } +const projectGroupingIndexCache = new WeakMap() + function projectHostKey(projectId: string, hostId: string): string { return `${projectId}::${hostId}` } function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { - const projects = model?.projects ?? [] - const projectHostSetups = model?.projectHostSetups ?? [] + if (!model) { + return null + } + const cached = projectGroupingIndexCache.get(model) + if (cached !== undefined) { + return cached + } + const projects = model.projects ?? [] + const projectHostSetups = model.projectHostSetups ?? [] if (projects.length === 0 || projectHostSetups.length === 0) { + projectGroupingIndexCache.set(model, null) return null } const setupCountByProjectHost = new Map() @@ -166,18 +176,27 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin multiSetupProjectHostKeys.add(key) } } - return { + const index = { projectById: new Map(projects.map((project) => [project.id, project])), setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), multiSetupProjectHostKeys } + projectGroupingIndexCache.set(model, index) + return index +} + +export type ProjectHeaderRevealTarget = { + key: string + label: string + repo?: Repo + projectId?: string } function getProjectGroupingForRepo( repoId: string, repoMap: Map, projectIndex: ProjectGroupingIndex | null -): { key: string; label: string; repo?: Repo; projectId?: string } { +): ProjectHeaderRevealTarget { const repo = repoMap.get(repoId) const setup = projectIndex?.setupByRepoId.get(repoId) const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined @@ -207,6 +226,14 @@ function getProjectGroupingForRepo( } } +export function getProjectHeaderRevealTarget( + repoId: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): ProjectHeaderRevealTarget { + return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping)) +} + function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { group.repoIds.add(repoId) } @@ -1153,8 +1180,17 @@ export function buildRows( appendProjectGroup(projectGroup, 0) } + const remainingRepoEntries = [...(groupByProjectGroupId.get(null) ?? [])] + for (const [projectGroupId, entries] of groupByProjectGroupId) { + if (projectGroupId === null || projectGroupsById.has(projectGroupId)) { + continue + } + // Why: startup can have repos from hosts whose project-group metadata was + // not fetched yet; missing metadata must not make those repos disappear. + remainingRepoEntries.push(...entries) + } appendOrderedGroups( - withRepoSectionDisplayLabels(sortRepoEntriesWithinGroup(groupByProjectGroupId.get(null) ?? [])), + withRepoSectionDisplayLabels(sortRepoEntriesWithinGroup(remainingRepoEntries)), 0 ) @@ -1217,9 +1253,15 @@ export function getGroupKeysForWorktree( const visited = new Set() let currentGroupId = repo?.projectGroupId ?? null while (currentGroupId && !visited.has(currentGroupId)) { + const group = groupsById.get(currentGroupId) + if (!group) { + // Why: repos can arrive before their remote Project Group metadata; reveal + // keys must match the top-level fallback rows buildRows actually renders. + break + } visited.add(currentGroupId) groupIds.unshift(currentGroupId) - const parentId = groupsById.get(currentGroupId)?.parentGroupId ?? null + const parentId = group.parentGroupId ?? null currentGroupId = parentId && groupsById.has(parentId) ? parentId : null } return [...groupIds.map((id) => getProjectGroupHeaderKey(id)), groupKey] diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts index ce3d07f22f5..cb9d12aeaf5 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { - FLUSH_CARD_MIN_CONTENT_INSET, FLUSH_CARD_CONTENT_PULLBACK, + FLUSH_CARD_MIN_CONTENT_INSET, + NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK, LINEAGE_CHILDREN_INLINE_OFFSET, LINEAGE_IMMEDIATE_PARENT_STEP, LINEAGE_NESTED_ROW_SURFACE_INSET, @@ -222,8 +223,15 @@ describe('worktree list indentation', () => { expect(getFlushWorktreeCardPaddingLeft(20)).toBe('max(2px, calc(20px - 4px))') }) + it('pulls experimental flush cards back further for the fixed status lane', () => { + expect(getFlushWorktreeCardPaddingLeft(20, true)).toBe( + `max(2px, calc(20px - ${FLUSH_CARD_CONTENT_PULLBACK + NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK}px))` + ) + }) + it('keeps flush card content off the sidebar edge without indentation', () => { expect(getFlushWorktreeCardPaddingLeft(0)).toBe('2px') + expect(getFlushWorktreeCardPaddingLeft(0, true)).toBe('2px') }) it('derives the lineage parent-child step from the pre-refactor grouped-card anchor', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-indentation.ts b/src/renderer/src/components/sidebar/worktree-list-indentation.ts index 28c87b56662..08787a8ae25 100644 --- a/src/renderer/src/components/sidebar/worktree-list-indentation.ts +++ b/src/renderer/src/components/sidebar/worktree-list-indentation.ts @@ -6,6 +6,9 @@ const PROJECT_WORKTREE_CARD_EXTRA_INDENT = 2 // raw tree indent to sit under the group header. A smaller pullback nudges // content rightward for clearer nesting; this is the knob to tune that gap. export const FLUSH_CARD_CONTENT_PULLBACK = 4 +// Why: experimental cards reserve a fixed status lane inside the padded +// content box; pull the box back so title/meta text stay on the tree step. +export const NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK = 6 // Why: even at zero indent a flush card keeps this minimal left inset so its // surface never sits hard against the sidebar edge. export const FLUSH_CARD_MIN_CONTENT_INSET = 2 @@ -161,12 +164,43 @@ export function getWorktreeCardSurfaceInset(args: { return args.isGrouped ? clampDepth(args.groupDepth) * GROUPED_WORKTREE_CARD_SURFACE_INDENT : 0 } -export function getFlushWorktreeCardPaddingLeft(contentIndent: number): string { +export function getFlushWorktreeCardPaddingLeft( + contentIndent: number, + applyNewCardStyleStatusLaneOffset = false +): string { + const pullback = + FLUSH_CARD_CONTENT_PULLBACK + + (applyNewCardStyleStatusLaneOffset ? NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK : 0) return contentIndent > 0 - ? `max(${FLUSH_CARD_MIN_CONTENT_INSET}px, calc(${contentIndent}px - ${FLUSH_CARD_CONTENT_PULLBACK}px))` + ? `max(${FLUSH_CARD_MIN_CONTENT_INSET}px, calc(${contentIndent}px - ${pullback}px))` : `${FLUSH_CARD_MIN_CONTENT_INSET}px` } +export function getNewCardStyleParentContentMarginLeft(contentIndent: number): number { + if (contentIndent <= 0) { + return 0 + } + + const legacyInnerPadding = Math.max( + FLUSH_CARD_MIN_CONTENT_INSET, + contentIndent - FLUSH_CARD_CONTENT_PULLBACK + ) + const newInnerPadding = Math.max( + FLUSH_CARD_MIN_CONTENT_INSET, + contentIndent - FLUSH_CARD_CONTENT_PULLBACK - NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK + ) + const paddingShift = legacyInnerPadding - newInnerPadding + const remainingShift = NEW_CARD_STYLE_STATUS_LANE_EXTRA_PULLBACK - paddingShift + if (remainingShift <= 0) { + return 0 + } + + // Why: shallow rows hit the flush-card padding floor; finish the status-lane + // offset with margin, but never pull content past the card's inner edge. + const rawMargin = -remainingShift + return Math.max(-newInnerPadding, rawMargin) +} + export function getLineageNestedRowGeometry(args: { experimentalNewWorktreeCardStyle: boolean inheritedCardContentIndent: number 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 index e0449b6eeca..cb742a12649 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.test.ts @@ -5,6 +5,7 @@ import { extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, getStickyHeaderIndexes, + pruneStaleVirtualRowElementCache, type RenderRow } from './worktree-list-virtual-rows' @@ -144,3 +145,49 @@ describe('extractWorktreeVirtualRowIndexes', () => { expect(indexes).toContain(0) }) }) + +describe('pruneStaleVirtualRowElementCache', () => { + it('removes stale measured row elements before they retain old WorktreeCard scopes', () => { + const activeElement = { + isConnected: true, + getAttribute: (name: string) => + name === 'data-worktree-virtual-row-key' ? 'wt:active' : null + } as Element + const staleElement = { + isConnected: false, + getAttribute: (name: string) => (name === 'data-worktree-virtual-row-key' ? 'wt:stale' : null) + } as Element + const connectedStaleElement = { + isConnected: true, + getAttribute: (name: string) => + name === 'data-worktree-virtual-row-key' ? 'wt:connected-stale' : null + } as Element + const retainedScope = { + defaultHostId: 'runtime:env-1', + handlerName: 'handleOpenReviewInOrca' + } + Object.assign(staleElement, { __retainedWorktreeCardScopeForTest: retainedScope }) + + const virtualizer = { + elementsCache: new Map([ + ['wt:active', activeElement], + ['wt:stale', staleElement], + ['wt:connected-stale', connectedStaleElement] + ]), + measureElement: (element: Element | null) => { + if (element) { + throw new Error('stale cache pruning should not remeasure rows') + } + } + } + + pruneStaleVirtualRowElementCache({ + activeRowKeys: new Set(['wt:active']), + virtualizer + }) + + expect(virtualizer.elementsCache.get('wt:active')).toBe(activeElement) + expect(virtualizer.elementsCache.has('wt:stale')).toBe(false) + expect(virtualizer.elementsCache.get('wt:connected-stale')).toBe(connectedStaleElement) + }) +}) 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 e98b9c1ee09..93daaf4271c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -76,6 +76,31 @@ export function getVirtualRowTransform(start: number): string { return `translateY(${start}px)` } +type VirtualRowElementCache = { + elementsCache: Map + measureElement: (node: TElement | null) => void +} + +export function pruneStaleVirtualRowElementCache({ + activeRowKeys, + virtualizer +}: { + activeRowKeys: ReadonlySet + virtualizer: VirtualRowElementCache +}): void { + virtualizer.measureElement(null) + for (const [key, element] of virtualizer.elementsCache) { + const rowKey = String(key) + if (activeRowKeys.has(rowKey) || element.isConnected) { + continue + } + // Why: measured row nodes retain their React fiber tree. Once TanStack's + // public null-measure cleanup has run, drop any disconnected stale key left + // behind so old WorktreeCard scopes do not survive runtime-host row churn. + virtualizer.elementsCache.delete(key) + } +} + export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] { const indexes: number[] = [] rows.forEach((row, index) => { diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index f9f9e72afb5..67095fee7c8 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -68,6 +68,11 @@ import { getResourceManagerAriaLabel, getResourceManagerTooltipLines } from './resource-manager-terminal-copy' +import { + buildResourceSessionBindingIndex, + countUnboundDaemonSessions, + type ResourceSessionBindingInputs +} from './resource-session-bindings' import { translate } from '@/i18n/i18n' const POLL_MS = 2_000 @@ -732,6 +737,8 @@ export function ResourceUsageStatusSegment({ const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) + const tabsByWorktreeForPtyBindings = useAppStore((s) => s.tabsByWorktree) + const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) const setActiveView = useAppStore((s) => s.setActiveView) const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) @@ -778,6 +785,17 @@ export function ResourceUsageStatusSegment({ // While a runtime server is active, hiding local samples avoids showing or // killing sessions from the wrong machine. const resourceSnapshot = runtimeEnvironmentActive ? null : snapshot + // Why: ptyIdsByTabId intentionally tracks mounted/live panes only. Resource + // Manager also reads restored wake hints, but only for classification. + const resourceSessionBindings = useMemo( + () => ({ + ptyIdsByTabId, + tabsByWorktree: tabsByWorktreeForPtyBindings, + terminalLayoutsByTabId, + workspaceSessionReady + }), + [ptyIdsByTabId, tabsByWorktreeForPtyBindings, terminalLayoutsByTabId, workspaceSessionReady] + ) // Why: after a kill confirms and the session unmounts, focus would otherwise // fall to . We park a ref on the popover body so we can restore focus @@ -947,6 +965,7 @@ export function ResourceUsageStatusSegment({ ? mergeSnapshotAndSessions(resourceSnapshot, sessions, { tabsByWorktree, ptyIdsByTabId, + terminalLayoutsByTabId, runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, @@ -960,6 +979,7 @@ export function ResourceUsageStatusSegment({ sessions, tabsByWorktree, ptyIdsByTabId, + terminalLayoutsByTabId, runtimePaneTitlesByTabId, workspaceSessionReady, repoDisplayNameById, @@ -969,28 +989,12 @@ export function ResourceUsageStatusSegment({ // Why: orphanCount drives the trigger badge (always visible in the status // bar, popover open or not) so it must compute outside the open-gate. - // Build the bound set with a single flat walk instead of nested Object - // iterations to keep this light on every store update. const orphanCount = useMemo(() => { if (!workspaceSessionReady || runtimeEnvironmentActive) { return 0 } - const bound = new Set() - for (const ids of Object.values(ptyIdsByTabId)) { - for (const id of ids) { - if (id) { - bound.add(id) - } - } - } - let n = 0 - for (const s of sessions) { - if (!bound.has(s.id)) { - n++ - } - } - return n - }, [sessions, ptyIdsByTabId, workspaceSessionReady, runtimeEnvironmentActive]) + return countUnboundDaemonSessions(sessions, resourceSessionBindings) + }, [sessions, resourceSessionBindings, workspaceSessionReady, runtimeEnvironmentActive]) const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => { const memory = resourceSnapshot?.totalMemory ?? 0 @@ -1123,14 +1127,7 @@ export function ResourceUsageStatusSegment({ if (!workspaceSessionReady) { return } - const bound = new Set() - for (const ids of Object.values(ptyIdsByTabId)) { - for (const id of ids) { - if (id) { - bound.add(id) - } - } - } + const bound = buildResourceSessionBindingIndex(resourceSessionBindings).boundPtyIds const orphans = sessions.filter((s) => !bound.has(s.id)) if (orphans.length === 0) { return @@ -1141,7 +1138,7 @@ export function ResourceUsageStatusSegment({ setSessions((prev) => prev.filter((s) => !orphanIds.has(s.id))) await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id))) void refreshSessions() - }, [sessions, ptyIdsByTabId, workspaceSessionReady, refreshSessions]) + }, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions]) const runKillConfirmed = useCallback(async () => { if (!killConfirm) { diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index 06ed8b088a4..ad0d13dd0da 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -186,6 +186,27 @@ describe('mergeSnapshotAndSessions', () => { expect(out[0].worktrees[0].sessions[0].bound).toBe(true) }) + it('treats startup deferred reattach tab ptyId wake hints as bound sessions', () => { + const tabId = 'tab-restored' + const sessionId = 'orca::/Users/me/Triton@@deferred' + const ds: DaemonSession[] = [{ id: sessionId, cwd: '/Users/me/Triton', title: 'orca/Triton' }] + const restoredTab = { ...makeTab(tabId, 'Restored'), ptyId: sessionId } + const ctx = baseCtx({ + tabsByWorktree: { + 'orca::/Users/me/Triton': [restoredTab] + }, + ptyIdsByTabId: { [tabId]: [] } + }) + + const out = mergeSnapshotAndSessions(null, ds, ctx) + + expect(out[0].worktrees[0].sessions[0]).toMatchObject({ + sessionId, + bound: true, + tabId + }) + }) + it('repo aggregate sums only worktrees with numeric metrics; remote-by-connectionId flags chip', () => { // Why: a single repo can be both reflected as a snapshot worktree // (covered by the local collector) and a daemon-only session diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts index 82aa54835fa..6fcfa3b2acf 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts @@ -31,6 +31,7 @@ import type { UnifiedSessionRow, UnifiedWorktreeRow } from './resource-usage-merge-types' +import { buildResourceSessionBindingIndex } from './resource-session-bindings' // ─── Helpers ──────────────────────────────────────────────────────── @@ -124,34 +125,6 @@ function resolveDaemonSessionLabel( return 'unknown' } -// Why: the previous implementation did O(N) linear scans over -// ptyIdsByTabId / tabsByWorktree for *every* session it processed. With a -// large workspace that's S * (T + W) work per merge — and the merge runs on -// every snapshot poll plus every store mutation. Pre-build O(1) lookup -// indices once per merge instead. -type MergeIndex = { - ptyIdToTabId: Map - tabIdToWorktreeId: Map -} - -function buildMergeIndex(ctx: MergeContext): MergeIndex { - const ptyIdToTabId = new Map() - for (const [tabId, ptyIds] of Object.entries(ctx.ptyIdsByTabId)) { - for (const ptyId of ptyIds) { - if (ptyId) { - ptyIdToTabId.set(ptyId, tabId) - } - } - } - const tabIdToWorktreeId = new Map() - for (const [worktreeId, tabs] of Object.entries(ctx.tabsByWorktree)) { - for (const tab of tabs) { - tabIdToWorktreeId.set(tab.id, worktreeId) - } - } - return { ptyIdToTabId, tabIdToWorktreeId } -} - // ─── Public merge function ───────────────────────────────────────── export const UNATTRIBUTED_REPO_ID = '__unattributed__' @@ -164,13 +137,11 @@ export function mergeSnapshotAndSessions( ): UnifiedProjectGroup[] { const repos = new Map() const seenSessionIds = new Set() - const index = buildMergeIndex(ctx) - // Why: bound = the daemon session id appears as a pty id under some tab. - // ptyIdToTabId already encodes that membership in O(1), so the bound set - // is just its keys. - const boundPtyIds = ctx.workspaceSessionReady - ? new Set(index.ptyIdToTabId.keys()) - : new Set() + // Why: pre-build O(1) lookup indices once per merge. This includes live + // ptyIdsByTabId plus deferred-reattach wake hints, so restored inactive + // sessions do not appear as Resource Manager orphans before their pane mounts. + const index = buildResourceSessionBindingIndex(ctx) + const boundPtyIds = index.boundPtyIds function isRepoRemote(repoId: string): boolean { // Why: missing entry === we don't know about this repo (typically the diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.test.ts b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts new file mode 100644 index 00000000000..58b13ca0595 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-bindings.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + buildResourceSessionBindingIndex, + countUnboundDaemonSessions +} from './resource-session-bindings' + +function makeTab(id: string, ptyId: string | null = null): TerminalTab { + return { + id, + ptyId, + worktreeId: 'repo::/workspace', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + type: 'terminal', + paneCount: 1 + } as unknown as TerminalTab +} + +function makeLayout(ptyIdsByLeafId: Record): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId + } +} + +describe('resource session bindings', () => { + it('combines live PTYs with restored tab and split-pane wake hints', () => { + const index = buildResourceSessionBindingIndex({ + ptyIdsByTabId: { + 'tab-active': ['pty-live', 'pty-live'] + }, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-active'), makeTab('tab-restored', 'pty-restored')] + }, + terminalLayoutsByTabId: { + 'tab-restored': makeLayout({ + 'leaf-1': 'pty-leaf-a', + 'leaf-2': 'pty-leaf-b' + }) + }, + workspaceSessionReady: true + }) + + expect(index.ptyIdToTabId).toEqual( + new Map([ + ['pty-live', 'tab-active'], + ['pty-restored', 'tab-restored'], + ['pty-leaf-a', 'tab-restored'], + ['pty-leaf-b', 'tab-restored'] + ]) + ) + expect([...index.boundPtyIds].sort()).toEqual([ + 'pty-leaf-a', + 'pty-leaf-b', + 'pty-live', + 'pty-restored' + ]) + }) + + it('ignores stale layout-only PTYs after their tab is gone', () => { + const index = buildResourceSessionBindingIndex({ + ptyIdsByTabId: {}, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-live', null)] + }, + terminalLayoutsByTabId: { + 'tab-closed': makeLayout({ 'leaf-1': 'pty-closed' }) + }, + workspaceSessionReady: true + }) + + expect(index.ptyIdToTabId.has('pty-closed')).toBe(false) + expect(index.boundPtyIds.has('pty-closed')).toBe(false) + }) + + it('counts only daemon sessions without live or restorable tab bindings', () => { + const count = countUnboundDaemonSessions( + [ + { id: 'pty-live', cwd: '/workspace', title: 'live' }, + { id: 'pty-restored', cwd: '/workspace', title: 'restored' }, + { id: 'pty-orphan', cwd: '/tmp', title: 'orphan' } + ], + { + ptyIdsByTabId: { 'tab-live': ['pty-live'] }, + tabsByWorktree: { + 'repo::/workspace': [makeTab('tab-live'), makeTab('tab-restored', 'pty-restored')] + }, + terminalLayoutsByTabId: {}, + workspaceSessionReady: true + } + ) + + expect(count).toBe(1) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-session-bindings.ts b/src/renderer/src/components/status-bar/resource-session-bindings.ts new file mode 100644 index 00000000000..f90914f9669 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-bindings.ts @@ -0,0 +1,86 @@ +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import type { DaemonSession } from './resource-usage-merge-types' + +export type ResourceSessionBindingInputs = { + tabsByWorktree: Record + ptyIdsByTabId: Record + terminalLayoutsByTabId?: Record + workspaceSessionReady: boolean +} + +export type ResourceSessionBindingIndex = { + ptyIdToTabId: Map + tabIdToWorktreeId: Map + boundPtyIds: Set +} + +function addBinding( + ptyIdToTabId: Map, + tabId: string, + ptyId: string | null | undefined +): void { + if (!ptyId || ptyIdToTabId.has(ptyId)) { + return + } + ptyIdToTabId.set(ptyId, tabId) +} + +export function buildResourceSessionBindingIndex( + inputs: ResourceSessionBindingInputs +): ResourceSessionBindingIndex { + const ptyIdToTabId = new Map() + const tabIdToWorktreeId = new Map() + + for (const [worktreeId, tabs] of Object.entries(inputs.tabsByWorktree)) { + for (const tab of tabs) { + tabIdToWorktreeId.set(tab.id, worktreeId) + } + } + + for (const [tabId, ptyIds] of Object.entries(inputs.ptyIdsByTabId)) { + for (const ptyId of ptyIds) { + addBinding(ptyIdToTabId, tabId, ptyId) + } + } + + // Why: startup-deferred reattach intentionally leaves inactive tabs out of + // ptyIdsByTabId, but their daemon sessions are still owned by tab/layout + // wake hints. Resource Manager should not classify those as orphans. + for (const tabs of Object.values(inputs.tabsByWorktree)) { + for (const tab of tabs) { + addBinding(ptyIdToTabId, tab.id, tab.ptyId) + } + } + + for (const [tabId, layout] of Object.entries(inputs.terminalLayoutsByTabId ?? {})) { + if (!tabIdToWorktreeId.has(tabId)) { + continue + } + for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { + addBinding(ptyIdToTabId, tabId, ptyId) + } + } + + return { + ptyIdToTabId, + tabIdToWorktreeId, + boundPtyIds: inputs.workspaceSessionReady ? new Set(ptyIdToTabId.keys()) : new Set() + } +} + +export function countUnboundDaemonSessions( + sessions: readonly DaemonSession[], + inputs: ResourceSessionBindingInputs +): number { + if (!inputs.workspaceSessionReady) { + return 0 + } + const { boundPtyIds } = buildResourceSessionBindingIndex(inputs) + let count = 0 + for (const session of sessions) { + if (!boundPtyIds.has(session.id)) { + count += 1 + } + } + return count +} diff --git a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts index 5cf962fcd10..88791b5d36e 100644 --- a/src/renderer/src/components/status-bar/resource-usage-merge-types.ts +++ b/src/renderer/src/components/status-bar/resource-usage-merge-types.ts @@ -1,4 +1,4 @@ -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' /** `null` === "no local sample" (e.g. SSH PTY); UI renders as em-dash. */ export type Metric = number | null @@ -50,6 +50,8 @@ export type MergeContext = { tabsByWorktree: Record /** From useAppStore: maps tabId -> ptyIds[] for the bound check. */ ptyIdsByTabId: Record + /** From useAppStore: persisted per-leaf PTY wake hints for deferred reattach. */ + terminalLayoutsByTabId?: Record /** From useAppStore: per-tab live pane titles (for label resolution). */ runtimePaneTitlesByTabId: Record> /** From useAppStore: false until renderer state can distinguish bound/orphan. */ diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 6694b357ddb..940ad6df996 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' -import { X, GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' +import { GitCompareArrows, Eye, ShieldAlert, Pin, ListChecks } from 'lucide-react' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { basename, normalizeRelativePath } from '@/lib/path' @@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { EditorFileTabCloseButton } from './EditorFileTabCloseButton' export default function EditorFileTab({ file, @@ -341,28 +342,14 @@ export default function EditorFileTab({ When clean: close button is shown normally (visible on active tab, on hover for others). */}
{file.isDirty && ( - + )} {!isPinned && ( - + )}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx new file mode 100644 index 00000000000..fb135b92c9e --- /dev/null +++ b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx @@ -0,0 +1,56 @@ +import { X } from 'lucide-react' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' + +export function EditorFileTabCloseButton({ + fileIsDirty, + showsSelectionChrome, + onClose +}: { + fileIsDirty: boolean + showsSelectionChrome: boolean + onClose: () => void +}): React.JSX.Element { + const closeShortcut = useShortcutKeyDetails('tab.close') + + return ( + + + + + + + {translate('auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1', 'Close tab')} + + {closeShortcut.keys.length > 0 && ( + + )} + + + ) +} diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 934d93ecaa6..8167bdbda5c 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘⌥W')) +const shortcutLabelMock = vi.hoisted(() => vi.fn()) vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: function DropdownMenu(props: { children?: unknown }) { @@ -63,10 +63,10 @@ vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -// Why: the menu reads the live binding for tab.closeAll; stub it to a fixed -// label so the test asserts the shortcut is surfaced, not its platform glyphs. +// Why: the menu reads live shortcut bindings; stub them to fixed labels so +// the test asserts each assigned action surfaces its own shortcut chip. vi.mock('@/hooks/useShortcutLabel', () => ({ - useShortcutLabel: shortcutLabelMock + useOptionalShortcutLabel: shortcutLabelMock })) const useAppStoreMock = Object.assign( @@ -208,10 +208,23 @@ async function renderMenu(): Promise { }) } +function assignedShortcutLabel(actionId: string): string | null { + switch (actionId) { + case 'tab.rename': + return '⌘R' + case 'tab.close': + return '⌘W' + case 'tab.closeAll': + return '⌘⌥W' + default: + return null + } +} + describe('EditorFileTabContextMenu close-all shortcut', () => { beforeEach(() => { vi.resetModules() - shortcutLabelMock.mockReturnValue('⌘⌥W') + shortcutLabelMock.mockImplementation(assignedShortcutLabel) vi.stubGlobal('navigator', { userAgent: 'Mac' }) }) @@ -219,26 +232,37 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { vi.unstubAllGlobals() }) - it('renders the tab.closeAll shortcut next to Close All Editor Tabs', async () => { + it('renders assigned shortcuts next to Rename, Close, and Close All Editor Tabs', async () => { const tree = expandNode(await renderMenu()) + const menuItems = findElementsByType(tree, 'DropdownMenuItem') - const closeAllItem = findElementsByType(tree, 'DropdownMenuItem').find((item) => + const renameItem = menuItems.find((item) => extractText(item.props.children).includes('Rename')) + const closeItem = menuItems.find((item) => extractText(item.props.children) === 'Close⌘W') + const closeAllItem = menuItems.find((item) => extractText(item.props.children).includes('Close All Editor Tabs') ) + expect(renameItem).toBeTruthy() + expect(closeItem).toBeTruthy() expect(closeAllItem).toBeTruthy() - const shortcut = findElementsByType(closeAllItem, 'DropdownMenuShortcut') - expect(shortcut).toHaveLength(1) - expect(extractText(shortcut[0].props.children)).toBe('⌘⌥W') + const shortcutExpectations: [ReactElementLike | undefined, string][] = [ + [renameItem, '⌘R'], + [closeItem, '⌘W'], + [closeAllItem, '⌘⌥W'] + ] - // Why: the shortcut hint is exclusive to Close All; sibling items (Close, - // Close Tabs To The Right) must not sprout their own chips. - expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(1) + for (const [item, expectedLabel] of shortcutExpectations) { + const shortcut = findElementsByType(item, 'DropdownMenuShortcut') + expect(shortcut).toHaveLength(1) + expect(extractText(shortcut[0].props.children)).toBe(expectedLabel) + } + + expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3) }) it('hides the shortcut chip when close-all is unassigned', async () => { - shortcutLabelMock.mockReturnValue('Unassigned') + shortcutLabelMock.mockReturnValue(null) const tree = expandNode(await renderMenu()) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index 95943a77156..184c05d2d97 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import type { OpenFile } from '../../store/slices/editor' import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' import { translate } from '@/i18n/i18n' @@ -81,8 +81,9 @@ export function EditorFileTabContextMenu({ onCloseToRight, onOpenMarkdownPreview }: EditorFileTabContextMenuProps): React.JSX.Element { - const closeAllShortcut = useShortcutLabel('tab.closeAll') - const showCloseAllShortcut = closeAllShortcut !== 'Unassigned' + const renameShortcut = useOptionalShortcutLabel('tab.rename') + const closeShortcut = useOptionalShortcutLabel('tab.close') + const closeAllShortcut = useOptionalShortcutLabel('tab.closeAll') return ( @@ -116,6 +117,7 @@ export function EditorFileTabContextMenu({ > {translate('auto.components.tab.bar.EditorFileTabContextMenu.68cc610e7f', 'Rename')} + {renameShortcut ? {renameShortcut} : null} @@ -128,13 +130,14 @@ export function EditorFileTabContextMenu({ !isPinned && onClose()} disabled={isPinned}> {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} + {closeShortcut ? {closeShortcut} : null} {translate( 'auto.components.tab.bar.EditorFileTabContextMenu.ba1369dd24', 'Close All Editor Tabs' )} - {showCloseAllShortcut ? ( + {closeAllShortcut ? ( {closeAllShortcut} ) : null} diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index 91d25847e87..3c32fdd7c99 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -1,5 +1,156 @@ -import { describe, expect, it } from 'vitest' -import { shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' + +const { shortcutLabelMock, storeState, openSettingsPageMock, openSettingsTargetMock } = vi.hoisted( + () => ({ + shortcutLabelMock: vi.fn<() => string | null>(), + storeState: { + settings: { + defaultTuiAgent: 'codex' as 'claude' | 'codex' | 'gemini' | 'blank' | null, + disabledTuiAgents: [] as string[] + }, + worktreesByRepo: {}, + repos: [], + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn() + }, + openSettingsPageMock: vi.fn(), + openSettingsTargetMock: vi.fn() + }) +) + +vi.mock('@/hooks/useDetectedAgents', () => ({ + useDetectedAgents: () => ({ detectedIds: ['claude', 'codex', 'gemini'] }) +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useOptionalShortcutLabel: shortcutLabelMock +})) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector: (state: typeof storeState) => unknown) => { + return selector(storeState) + }, + { + getState: () => storeState + } + ) + + return { useAppStore } +}) + +vi.mock('@/lib/agent-catalog', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + getAgentCatalog: () => [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'gemini', label: 'Gemini' } + ], + AgentIcon: ({ agent }: { agent: string }) => ReactActual.createElement('span', null, agent) + } +}) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const ReactActual = (await vi.importActual('react')) as { + createElement: typeof React.createElement + } + + return { + DropdownMenuItem: ({ children, ...props }: { children: React.ReactNode }) => + ReactActual.createElement('div', props, children), + DropdownMenuShortcut: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement('span', { 'data-dropdown-shortcut': 'true' }, children) + } +}) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + Object.entries(values ?? {}).reduce( + (text, [key, value]) => text.replace(`{{${key}}}`, value), + fallback + ) +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: vi.fn() +})) + +function renderAgentMenuItems(): string { + return renderToStaticMarkup( + React.createElement(QuickLaunchAgentMenuItems, { + worktreeId: 'worktree-1', + groupId: 'group-1', + onFocusTerminal: vi.fn() + }) + ) +} + +function rowMarkup(html: string, label: string): string { + const start = html.indexOf(`title="Launch ${label} in a new terminal"`) + expect(start).toBeGreaterThanOrEqual(0) + const end = html.indexOf('
', start) + expect(end).toBeGreaterThan(start) + + return html.slice(start, end) +} + +beforeEach(() => { + shortcutLabelMock.mockReset() + shortcutLabelMock.mockReturnValue(null) + openSettingsPageMock.mockReset() + openSettingsTargetMock.mockReset() + storeState.settings.defaultTuiAgent = 'codex' + storeState.settings.disabledTuiAgents = [] + storeState.worktreesByRepo = {} + storeState.repos = [] + storeState.openSettingsPage = openSettingsPageMock + storeState.openSettingsTarget = openSettingsTargetMock +}) + +describe('QuickLaunchAgentMenuItems', () => { + it('renders the new-agent shortcut next to the configured default agent only', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + const html = renderAgentMenuItems() + + expect(html.match(/data-dropdown-shortcut="true"/g) ?? []).toHaveLength(1) + expect(rowMarkup(html, 'Codex')).toContain('⌘⌥T') + expect(rowMarkup(html, 'Claude')).not.toContain('⌘⌥T') + expect(rowMarkup(html, 'Gemini')).not.toContain('⌘⌥T') + }) + + it('hides the default-agent shortcut when the action is unbound', () => { + shortcutLabelMock.mockReturnValue(null) + + const html = renderAgentMenuItems() + + expect(html).not.toContain('data-dropdown-shortcut="true"') + }) + + it('does not label an auto-picked or blank default as configured', () => { + shortcutLabelMock.mockReturnValue('⌘⌥T') + + storeState.settings.defaultTuiAgent = null + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + + storeState.settings.defaultTuiAgent = 'blank' + expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') + }) +}) describe('shouldShowLaunchWatchdogTimeout', () => { it('does not report slow agent readiness once a PTY exists', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 5e4b0f9ce57..84681bbc8cc 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from 'react' import { Settings as SettingsIcon } from 'lucide-react' import { toast } from 'sonner' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu' import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { useDetectedAgents } from '@/hooks/useDetectedAgents' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import type { TuiAgent } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -116,6 +117,7 @@ function QuickLaunchAgentMenuItemsInner({ const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -197,6 +199,8 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent + const showsDefaultAgentShortcut = + newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( - {label} + {label} + {showsDefaultAgentShortcut ? ( + {newAgentShortcut} + ) : null} ) })} diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index b47ec987e66..b22e1912d9e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -106,7 +106,9 @@ vi.mock('lucide-react', () => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘⇧\\' + formatShortcutLabel: () => '⌘⇧\\', + useOptionalShortcutLabel: () => '⌘W', + useShortcutKeyDetails: () => ({ keys: ['⌘', 'W'], doubleTap: false }) })) vi.mock('@/components/ui/dropdown-menu', () => ({ @@ -119,12 +121,12 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) { return { type: 'DropdownMenuItem', props } }, - DropdownMenuSeparator: function DropdownMenuSeparator() { - return { type: 'DropdownMenuSeparator', props: {} } - }, DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) { return { type: 'DropdownMenuShortcut', props } }, + DropdownMenuSeparator: function DropdownMenuSeparator() { + return { type: 'DropdownMenuSeparator', props: {} } + }, DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) { return { type: 'DropdownMenuLabel', props } }, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 80b625f6885..41426802753 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -7,6 +7,7 @@ import { stripLeadingAgentTitleDecoration } from '@/lib/agent-title-decoration' import { useTabAgent } from '@/lib/use-tab-agent' import { Input } from '@/components/ui/input' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { FilledBellIcon } from '../sidebar/WorktreeCardHelpers' @@ -24,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' type SortableTabProps = { tab: TerminalTab @@ -207,6 +209,7 @@ export default function SortableTab({ disabled: isEditing }) const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed) + const closeShortcut = useShortcutKeyDetails('tab.close') const tabTitle = tab.customTitle ?? tab.title const tabRoot = (
)} {!isEditing && !isPinned && ( - + + + + + + {translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab')} + {closeShortcut.keys.length > 0 && ( + + )} + + )}
) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 391eb8ddb7c..71b067fa898 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -18,7 +18,8 @@ const storeMock = vi.hoisted(() => ({ })) vi.mock('@/hooks/useShortcutLabel', () => ({ - formatShortcutLabel: () => '⌘D' + formatShortcutLabel: () => '⌘D', + useOptionalShortcutLabel: () => '⌘D' })) vi.mock('@/components/ui/dropdown-menu', () => ({ diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 2997ab2a29c..291deb92466 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' -import { formatShortcutLabel } from '@/hooks/useShortcutLabel' +import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split' @@ -126,6 +126,8 @@ export function SortableTabContextMenu({ } requestActiveTerminalPaneSplit({ tabId: tab.id, direction }) } + const closeShortcut = useOptionalShortcutLabel('tab.close') + const renameShortcut = useOptionalShortcutLabel('tab.rename') return ( @@ -165,6 +167,7 @@ export function SortableTabContextMenu({ !isPinned && onClose(tab.id)} disabled={isPinned}> {translate('auto.components.tab.bar.SortableTabContextMenu.89359a36f7', 'Close')} + {closeShortcut ? {closeShortcut} : null} onCloseOthers(tab.id)} disabled={tabCount <= 1}> {translate('auto.components.tab.bar.SortableTabContextMenu.8d16f9cd30', 'Close Others')} @@ -178,6 +181,7 @@ export function SortableTabContextMenu({ {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} + {renameShortcut ? {renameShortcut} : null}
diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index fd5d8ca9a90..9e313bccd55 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -465,7 +465,7 @@ describe('TabBar context menu wiring', () => { ) expect(menuLabels[0]).toContain('New Markdown') - expect(menuLabels[1]).toBe('Open Markdown...') + expect(menuLabels[1]).toContain('Open Markdown...') expect(menuLabels[2]).toContain('New Terminal') expect(menuLabels[3]).toContain('New Browser Tab') }) diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 0455c4fb64b..f61ff4aff33 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -51,7 +51,7 @@ import { import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { useOptionalShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel' import { type BuiltInWindowsTerminalShell, WINDOWS_GIT_BASH_SHELL @@ -272,6 +272,7 @@ function TabBarInner({ const newBrowserShortcut = useShortcutLabel('tab.newBrowser') const newSimulatorShortcut = useShortcutLabel('tab.newSimulator') const newFileShortcut = useShortcutLabel('tab.newMarkdown') + const openMarkdownShortcut = useOptionalShortcutLabel('tab.openMarkdown') const generatedTabTitlesEnabled = useAppStore((s) => s.settings?.tabAutoGenerateTitle === true) const mobileEmulatorEnabled = useAppStore((s) => s.settings?.mobileEmulatorEnabled !== false) const persistedUIReady = useAppStore((s) => s.persistedUIReady) @@ -761,6 +762,9 @@ function TabBarInner({ > {translate('auto.components.tab.bar.TabBar.4f327c8b3d', 'Open Markdown...')} + {openMarkdownShortcut ? ( + {openMarkdownShortcut} + ) : null} ) : null const mobileEmulatorIntroMenuBlock = diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index f6fa605e219..920470534ad 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -173,6 +173,19 @@ function firstOpeningTag(markup: string): string { return match[0] } +function textSpanHtml(markup: string, text: string): string { + const textIndex = markup.indexOf(`>${text}`) + expect(textIndex).toBeGreaterThanOrEqual(0) + + const tagStart = markup.lastIndexOf('', textIndex) + expect(spanEnd).toBeGreaterThan(textIndex) + + return markup.slice(tagStart, spanEnd + ''.length) +} + function expectTabContainerWidth(markup: string, root: string): void { const container = firstOpeningTag(markup) const widthClasses = 'min-w-[88px] max-w-[280px] flex-[1_1_180px] min-[1280px]:flex-[1_1_220px]' @@ -357,6 +370,7 @@ describe('tab title tooltips', () => { expect(root).toContain('role="tab"') expect(root).toContain('tabindex="0"') expect(root).toContain('data-tab-id="editor-tab-1"') + expect(textSpanHtml(markup, 'VeryLongEditorFileName.tsx')).not.toContain('renamed') expectTabContainerWidth(markup, root) }) }) diff --git a/src/renderer/src/components/task-page-github-status-actions.test.ts b/src/renderer/src/components/task-page-github-status-actions.test.ts new file mode 100644 index 00000000000..b18226f6f88 --- /dev/null +++ b/src/renderer/src/components/task-page-github-status-actions.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget +} from './task-page-github-status-actions' +import type { GitHubWorkItem } from '../../../shared/types' + +describe('TaskPage GitHub status actions', () => { + it('builds completed and not planned close updates', () => { + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'completed' })).toEqual({ + state: 'closed', + stateReason: 'completed' + }) + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'not_planned' })).toEqual({ + state: 'closed', + stateReason: 'not_planned' + }) + }) + + it('builds duplicate close updates with a target issue number', () => { + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'duplicate', duplicateOf: 42 })).toEqual({ + state: 'closed', + stateReason: 'duplicate', + duplicateOf: 42 + }) + }) + + it('validates duplicate targets before dispatch', () => { + expect(validateTaskPageGitHubDuplicateTarget('', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('12', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('12.5', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('-1', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('0', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget(' 34 ', 12)).toEqual({ + ok: true, + duplicateOf: 34 + }) + }) + + it('maps duplicate validation failures to localized messages', () => { + const t = (_key: string, fallback: string): string => fallback + const validation = validateTaskPageGitHubDuplicateTarget('same', 12) + + expect(validation.ok).toBe(false) + if (!validation.ok) { + expect(getTaskPageGitHubDuplicateTargetErrorMessage(validation, t)).toBe( + 'Use a whole issue number.' + ) + } + }) + + it('filters duplicate candidates to other matching issues', () => { + const items = [ + buildWorkItem({ number: 12, title: 'Current issue', type: 'issue' }), + buildWorkItem({ number: 34, title: 'Copy SSH file support', type: 'issue' }), + buildWorkItem({ number: 56, title: 'Unrelated pull request', type: 'pr' }), + buildWorkItem({ number: 78, title: 'Windows path copy bug', type: 'issue' }) + ] + + expect(getTaskPageGitHubDuplicateCandidates(items, 12, '').map((item) => item.number)).toEqual([ + 34, 78 + ]) + expect( + getTaskPageGitHubDuplicateCandidates(items, 12, 'copy').map((item) => item.number) + ).toEqual([34, 78]) + expect( + getTaskPageGitHubDuplicateCandidates(items, 12, '34').map((item) => item.number) + ).toEqual([34]) + }) +}) + +function buildWorkItem(overrides: Partial): GitHubWorkItem { + return { + id: `item-${overrides.number ?? 1}`, + type: 'issue', + number: 1, + title: 'Issue', + state: 'open', + url: 'https://github.com/stablyai/orca/issues/1', + labels: [], + updatedAt: '2026-06-22T00:00:00.000Z', + author: null, + repoId: 'repo-1', + ...overrides + } +} diff --git a/src/renderer/src/components/task-page-github-status-actions.ts b/src/renderer/src/components/task-page-github-status-actions.ts new file mode 100644 index 00000000000..1d3ed687767 --- /dev/null +++ b/src/renderer/src/components/task-page-github-status-actions.ts @@ -0,0 +1,102 @@ +import type { + GitHubIssueCloseReason, + GitHubIssueUpdate, + GitHubWorkItem +} from '../../../shared/types' + +export type TaskPageGitHubCloseAction = + | { stateReason: Exclude } + | { stateReason: 'duplicate'; duplicateOf: number } + +export type TaskPageGitHubDuplicateValidation = + | { ok: true; duplicateOf: number } + | { + ok: false + reason: 'missing' | 'not_integer' | 'not_positive' | 'same_issue' + } + +export type TaskPageGitHubDuplicateValidationError = Extract< + TaskPageGitHubDuplicateValidation, + { ok: false } +> + +type TranslateDuplicateError = (key: string, fallback: string) => string + +export function buildTaskPageGitHubCloseUpdate( + action: TaskPageGitHubCloseAction +): GitHubIssueUpdate { + return { + state: 'closed', + stateReason: action.stateReason, + ...(action.stateReason === 'duplicate' ? { duplicateOf: action.duplicateOf } : {}) + } +} + +export function validateTaskPageGitHubDuplicateTarget( + value: string, + currentIssueNumber: number +): TaskPageGitHubDuplicateValidation { + const trimmed = value.trim() + if (!trimmed) { + return { ok: false, reason: 'missing' } + } + if (!/^\d+$/.test(trimmed)) { + return { ok: false, reason: 'not_integer' } + } + const duplicateOf = Number(trimmed) + if (!Number.isSafeInteger(duplicateOf) || duplicateOf <= 0) { + return { ok: false, reason: 'not_positive' } + } + if (duplicateOf === currentIssueNumber) { + return { ok: false, reason: 'same_issue' } + } + return { ok: true, duplicateOf } +} + +export function getTaskPageGitHubDuplicateTargetErrorMessage( + validation: TaskPageGitHubDuplicateValidationError, + translate: TranslateDuplicateError +): string { + switch (validation.reason) { + case 'missing': + return translate( + 'auto.components.TaskPage.duplicateIssueMissing', + 'Enter an issue number in this repository.' + ) + case 'not_integer': + return translate( + 'auto.components.TaskPage.duplicateIssueNotInteger', + 'Use a whole issue number.' + ) + case 'not_positive': + return translate( + 'auto.components.TaskPage.duplicateIssueNotPositive', + 'Use a positive issue number.' + ) + case 'same_issue': + return translate( + 'auto.components.TaskPage.duplicateIssueSameIssue', + 'Choose a different issue.' + ) + } +} + +export function getTaskPageGitHubDuplicateCandidates( + items: GitHubWorkItem[], + currentIssueNumber: number, + query: string +): GitHubWorkItem[] { + const normalizedQuery = query.trim().toLowerCase() + return items.filter((candidate) => { + if (candidate.type !== 'issue' || candidate.number === currentIssueNumber) { + return false + } + if (!normalizedQuery) { + return true + } + return ( + candidate.title.toLowerCase().includes(normalizedQuery) || + String(candidate.number).includes(normalizedQuery) + ) + }) +} diff --git a/src/renderer/src/components/task-page-github-work-item-status-badge.tsx b/src/renderer/src/components/task-page-github-work-item-status-badge.tsx new file mode 100644 index 00000000000..1932170e352 --- /dev/null +++ b/src/renderer/src/components/task-page-github-work-item-status-badge.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { + getTaskPageGitHubWorkItemStateLabel, + getTaskPageGitHubWorkItemStateTone, + type GitHubWorkItemStatusItem +} from '@/components/task-page-github-work-item-status' + +export function TaskPageGitHubWorkItemStateBadge({ + item, + className +}: { + item: GitHubWorkItemStatusItem + className?: string +}): React.JSX.Element { + return ( + + {getTaskPageGitHubWorkItemStateLabel(item)} + + ) +} diff --git a/src/renderer/src/components/task-page-github-work-item-status.test.ts b/src/renderer/src/components/task-page-github-work-item-status.test.ts new file mode 100644 index 00000000000..62ced71280d --- /dev/null +++ b/src/renderer/src/components/task-page-github-work-item-status.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { + getTaskPageGitHubPRIconTone, + getTaskPageGitHubWorkItemStateLabel, + getTaskPageGitHubWorkItemStateTone, + isTaskPageGitHubDraftPR +} from './task-page-github-work-item-status' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +describe('task-page-github-work-item-status', () => { + it('maps PR states to labels', () => { + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'draft' })).toBe('Draft') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'open' })).toBe('Open') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'closed' })).toBe('Closed') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'merged' })).toBe('Merged') + }) + + it('maps issue states to labels', () => { + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'open' })).toBe('Open') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'closed' })).toBe('Closed') + }) + + it('uses GitHub-like neutral draft tones and muted draft icons', () => { + const draftTone = getTaskPageGitHubWorkItemStateTone({ type: 'pr', state: 'draft' }) + expect(draftTone).toContain('muted') + expect(draftTone).not.toContain('amber') + expect(getTaskPageGitHubPRIconTone({ type: 'pr', state: 'draft' })).toContain('muted') + expect(getTaskPageGitHubPRIconTone({ type: 'pr', state: 'open' })).toContain('emerald') + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'draft' })).toBe(true) + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'open' })).toBe(false) + }) + + it('uses distinct tones for merged and open PRs', () => { + expect(getTaskPageGitHubWorkItemStateTone({ type: 'pr', state: 'merged' })).toContain('purple') + expect(getTaskPageGitHubWorkItemStateTone({ type: 'pr', state: 'open' })).toContain('emerald') + }) + + it('handles edge cases gracefully', () => { + // Issues don't have draft state - should fallback to open styling + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'open' })).toBe('Open') + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'issue', state: 'closed' })).toBe('Closed') + + // Unknown state should fallback to 'Open' for PRs + expect(getTaskPageGitHubWorkItemStateLabel({ type: 'pr', state: 'unknown' as 'open' })).toBe( + 'Open' + ) + + // Icon tones for issues should always be muted + expect(getTaskPageGitHubPRIconTone({ type: 'issue', state: 'open' })).toContain( + 'muted-foreground' + ) + expect(getTaskPageGitHubPRIconTone({ type: 'issue', state: 'closed' })).toContain( + 'muted-foreground' + ) + + // Draft PR check + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'draft' })).toBe(true) + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'open' })).toBe(false) + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'merged' })).toBe(false) + expect(isTaskPageGitHubDraftPR({ type: 'pr', state: 'closed' })).toBe(false) + expect(isTaskPageGitHubDraftPR({ type: 'issue', state: 'open' })).toBe(false) + expect(isTaskPageGitHubDraftPR({ type: 'issue', state: 'closed' })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/task-page-github-work-item-status.ts b/src/renderer/src/components/task-page-github-work-item-status.ts new file mode 100644 index 00000000000..b200abb2328 --- /dev/null +++ b/src/renderer/src/components/task-page-github-work-item-status.ts @@ -0,0 +1,68 @@ +import { translate } from '@/i18n/i18n' +import type { GitHubWorkItem } from '../../../shared/types' + +export type GitHubWorkItemStatusItem = Pick + +export function getTaskPageGitHubWorkItemStateLabel(item: GitHubWorkItemStatusItem): string { + if (item.type === 'pr') { + if (item.state === 'merged') { + return translate('auto.components.github.pr.merge.state.83ecdbb4a6', 'Merged') + } + if (item.state === 'draft') { + return translate('auto.components.TaskPage.054bf695cc', 'Draft') + } + if (item.state === 'closed') { + return translate('auto.components.TaskPage.d09bf34db7', 'Closed') + } + return translate('auto.components.TaskPage.606a85c774', 'Open') + } + + return item.state === 'closed' + ? translate('auto.components.TaskPage.d09bf34db7', 'Closed') + : translate('auto.components.TaskPage.606a85c774', 'Open') +} + +// Why: mirror GitHub Primer StateLabel tones — draft is neutral gray, open is +// green, merged purple, closed red. No custom amber/dashed treatment. +// Note: Draft uses a different pattern (muted-foreground bg on border bg) because +// it's a non-actionable "meta" state, while other states use solid color fills. +export function getTaskPageGitHubWorkItemStateTone(item: GitHubWorkItemStatusItem): string { + if (item.type === 'pr') { + if (item.state === 'merged') { + return 'border-purple-600/20 bg-purple-600 text-purple-50 dark:border-purple-400/20 dark:bg-purple-500 dark:text-white' + } + if (item.state === 'draft') { + return 'border-border/60 bg-muted-foreground/70 text-background dark:bg-muted-foreground/60 dark:text-foreground' + } + if (item.state === 'closed') { + return 'border-rose-600/20 bg-rose-600 text-rose-50 dark:border-rose-400/20 dark:bg-rose-500 dark:text-white' + } + return 'border-emerald-600/20 bg-emerald-600 text-emerald-50 dark:border-emerald-400/20 dark:bg-emerald-500 dark:text-white' + } + + if (item.state === 'closed') { + return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + } + return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' +} + +export function isTaskPageGitHubDraftPR(item: GitHubWorkItemStatusItem): boolean { + return item.type === 'pr' && item.state === 'draft' +} + +export function getTaskPageGitHubPRIconTone(item: GitHubWorkItemStatusItem): string { + if (item.type !== 'pr') { + return 'text-muted-foreground' + } + + switch (item.state) { + case 'draft': + return 'text-muted-foreground' + case 'open': + return 'text-emerald-600 dark:text-emerald-400' + case 'merged': + return 'text-purple-600 dark:text-purple-300' + case 'closed': + return 'text-rose-600 dark:text-rose-300' + } +} diff --git a/src/renderer/src/components/task-page-jira-load-state.test.ts b/src/renderer/src/components/task-page-jira-load-state.test.ts new file mode 100644 index 00000000000..a5a151386c0 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-load-state.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { createTaskPageJiraLoadFailureState } from './task-page-jira-load-state' + +describe('TaskPage Jira load state', () => { + it('explains Jira forbidden errors while clearing stale issues', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Forbidden'))).toEqual({ + issues: [], + error: { + title: + 'Error 403: Jira denied access to this issue search. Check project permissions or try a different JQL query.', + details: 'Forbidden' + } + }) + }) + + it('keeps raw provider detail separate from the Jira status summary', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Error 403: XSRF check failed'))).toEqual({ + issues: [], + error: { + title: + 'Error 403: Jira denied access to this issue search. Check project permissions or try a different JQL query.', + details: 'XSRF check failed' + } + }) + }) + + it('explains malformed JQL errors', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Malformed JQL'))).toEqual({ + issues: [], + error: { + title: "Jira couldn't run this JQL query. Check the syntax and try again.", + details: 'Malformed JQL' + } + }) + }) + + it('explains network errors', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Network request failed'))).toEqual({ + issues: [], + error: { + title: "Couldn't reach Jira. Check your connection and try again.", + details: 'Network request failed' + } + }) + }) + + it('explains Jira server errors', () => { + expect(createTaskPageJiraLoadFailureState(new Error('Service Unavailable'))).toEqual({ + issues: [], + error: { + title: 'Error 503: Jira had a server error while loading issues. Try again in a moment.', + details: 'Service Unavailable' + } + }) + }) + + it('uses the generic load error for non-Error rejections', () => { + expect(createTaskPageJiraLoadFailureState('failed')).toEqual({ + issues: [], + error: { + title: "Couldn't load Jira issues. Try again in a moment.", + details: 'Failed to load Jira issues.' + } + }) + }) +}) diff --git a/src/renderer/src/components/task-page-jira-load-state.ts b/src/renderer/src/components/task-page-jira-load-state.ts new file mode 100644 index 00000000000..77826b78126 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-load-state.ts @@ -0,0 +1,76 @@ +import type { JiraIssue } from '../../../shared/types' + +export type TaskPageJiraLoadError = { + title: string + details: string | null +} + +export type TaskPageJiraLoadFailureState = { + issues: JiraIssue[] + error: TaskPageJiraLoadError +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Failed to load Jira issues.' +} + +function getErrorCode(message: string): number | null { + const explicit = /^Error\s+(\d{3})\b/i.exec(message)?.[1] + if (explicit) { + return Number(explicit) + } + if (/\bforbidden\b/i.test(message)) { + return 403 + } + if (/\bunauthorized\b|\bunauthenticated\b/i.test(message)) { + return 401 + } + if (/\btoo many requests\b|\brate limit\b/i.test(message)) { + return 429 + } + if (/\bservice unavailable\b/i.test(message)) { + return 503 + } + return null +} + +function getErrorDetails(message: string, code: number | null): string | null { + const normalized = + code === null ? message : message.replace(new RegExp(`^Error\\s+${code}:\\s*`, 'i'), '') + return normalized.trim() || null +} + +function getIssueSearchErrorSummary(message: string, code: number | null): string { + if (code === 401) { + return 'Jira authentication failed. Reconnect Jira in Settings, then try again.' + } + if (code === 403) { + return 'Jira denied access to this issue search. Check project permissions or try a different JQL query.' + } + if (code === 429) { + return 'Jira rate-limited this issue search. Try again in a moment.' + } + if (code !== null && code >= 500) { + return 'Jira had a server error while loading issues. Try again in a moment.' + } + if (/\bjql\b|\bsyntax\b/i.test(message)) { + return "Jira couldn't run this JQL query. Check the syntax and try again." + } + if (/\bnetwork\b|\bfetch failed\b|\btimed? ?out\b|\beconn/i.test(message)) { + return "Couldn't reach Jira. Check your connection and try again." + } + return "Couldn't load Jira issues. Try again in a moment." +} + +export function createTaskPageJiraLoadFailureState(error: unknown): TaskPageJiraLoadFailureState { + const message = getErrorMessage(error) + const code = getErrorCode(message) + const summary = getIssueSearchErrorSummary(message, code) + return { + issues: [], + error: { + title: code === null ? summary : `Error ${code}: ${summary}`, + details: getErrorDetails(message, code) + } + } +} 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 index a694b99c44c..1b1d98786a7 100644 --- a/src/renderer/src/components/task-page-source-switch-boundary.test.ts +++ b/src/renderer/src/components/task-page-source-switch-boundary.test.ts @@ -21,10 +21,11 @@ describe('TaskPage source switching host boundary', () => { ) const modalSection = sourceBetween( TASK_PAGE_SOURCE, - '', + '', ' useAppStore.getState().pendingStartupByTabId[tabId]) + const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState( + () => startup !== undefined && !isVisible + ) const [sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds] = useState>( () => new Set() ) - const shouldMeasureHiddenStartup = startup !== undefined && !isVisible const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand) const [setupSplit] = useState(() => useAppStore.getState().pendingSetupSplitByTabId[tabId]) const consumeTabSetupSplit = useAppStore((store) => store.consumeTabSetupSplit) @@ -502,6 +506,14 @@ export default function TerminalPane({ } }, [startup, tabId, consumeTabStartupCommand]) + useLayoutEffect(() => { + if (isVisible && shouldMeasureHiddenStartup) { + // Why: hidden startup measurement is only for first launch. Keeping it + // after first visibility lets inactive agent tabs refit and SIGWINCH. + setShouldMeasureHiddenStartup(false) + } + }, [isVisible, shouldMeasureHiddenStartup]) + const clearSessionRestoredBannerForPane = useCallback((paneId: number): void => { setSessionRestoredBannerPaneIds((prev) => { const next = removeSessionRestoredBannerPaneId(prev, paneId) @@ -1318,6 +1330,7 @@ export default function TerminalPane({ cwd, isActive, isVisible, + isWorktreeActive, // Why: hidden startup probes are opacity-hidden but measurable; ordinary // hidden tabs are display:none and refit on visibility resume instead. isSyncFitEnabled: isVisible || shouldMeasureHiddenStartup, @@ -1765,7 +1778,10 @@ export default function TerminalPane({ sessionRestoredBannerPaneIds, reservePaneHeaderSpace: isActive && (isVisible || shouldMeasureHiddenStartup) }) - if (needsFit) { + if (needsFit && (isVisible || shouldMeasureHiddenStartup)) { + // Why: inactive terminal tabs can drop active-only header reservation. + // Fitting that hidden geometry changes PTY rows and wakes TUIs with + // SIGWINCH; the visible resume path owns any real layout correction. fitPanes(manager) } }, [ diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 4bcf72a9350..3c13648f6dd 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -26,6 +26,8 @@ const HAS_CSS_ANCHOR_POSITIONING = CSS.supports('position-anchor', '--orca-terminal-overlay-probe') && CSS.supports('top', 'anchor(--orca-terminal-overlay-probe top)') && CSS.supports('width', 'anchor-size(--orca-terminal-overlay-probe width)') +const MIN_OVERLAY_FIT_WIDTH_PX = 48 +const MIN_OVERLAY_FIT_HEIGHT_PX = 24 function shouldUseCssAnchorPositioning(): boolean { return ( @@ -47,6 +49,7 @@ type TerminalOverlaySlotProps = { worktreeId: string worktreePath: string groupId: string | undefined + isWorktreeActive: boolean isVisible: boolean isActive: boolean activityTerminalPortal: ActivityTerminalPortalTarget | null @@ -62,6 +65,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ worktreeId, worktreePath, groupId, + isWorktreeActive, isVisible, isActive, activityTerminalPortal, @@ -75,9 +79,14 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ const [measuredFallbackRect, setMeasuredFallbackRect] = useState( null ) - const [shouldMeasureHiddenStartup] = useState( + const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState( () => useAppStore.getState().pendingStartupByTabId[terminalTabId] !== undefined ) + useLayoutEffect(() => { + if (isVisible && shouldMeasureHiddenStartup) { + setShouldMeasureHiddenStartup(false) + } + }, [isVisible, shouldMeasureHiddenStartup]) useLayoutEffect(() => { if (!anchorName || shouldUseCssAnchorPositioning() || !groupId) { return @@ -128,21 +137,37 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ }, [anchorName, groupId, isVisible]) useLayoutEffect(() => { - if (!isVisible || !anchorName || shouldUseCssAnchorPositioning()) { + if (!isVisible || !anchorName) { return } - // Why: worktree switches resume visibility before fallback positioning - // settles. Re-fit on show and again after the measured rect lands so the - // PTY never stays pinned at a stale ~2-col width. - const frameId = requestAnimationFrame(() => { + const dispatchFitIfMeasurable = (): void => { + const rect = overlayRef.current?.getBoundingClientRect() + if ( + !rect || + rect.width < MIN_OVERLAY_FIT_WIDTH_PX || + rect.height < MIN_OVERLAY_FIT_HEIGHT_PX + ) { + return + } window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT)) + } + + // Why: tab switches can resume visibility before anchor/fallback geometry + // settles. Re-fit only after the overlay has real dimensions so the PTY + // never stays pinned at a stale ~2-col width. + const frameId = requestAnimationFrame(() => { + dispatchFitIfMeasurable() }) const retryId = window.setTimeout(() => { - window.dispatchEvent(new Event(SYNC_FIT_PANES_EVENT)) + dispatchFitIfMeasurable() }, 50) + const settledRetryId = window.setTimeout(() => { + dispatchFitIfMeasurable() + }, 150) return () => { cancelAnimationFrame(frameId) window.clearTimeout(retryId) + window.clearTimeout(settledRetryId) } }, [anchorName, isVisible, measuredFallbackRect]) @@ -202,6 +227,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ // TerminalPane mounted here preserves alt-screen TUI state while this // flag still lets hidden tabs throttle rendering. isVisible={isVisible || activityTerminalPortal !== null} + isWorktreeActive={isWorktreeActive || activityTerminalPortal !== null} isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} onPtyExit={(ptyId) => { if (consumeSuppressedPtyExit(ptyId)) { @@ -332,6 +358,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId={worktreeId} worktreePath={worktreePath} groupId={assignment?.groupId} + isWorktreeActive={isWorktreeActive} isVisible={isVisible} isActive={isActive} activityTerminalPortal={activityTerminalPortal} 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 ab99e7b28a3..da2705e5288 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4057,6 +4057,60 @@ describe('connectPanePty', () => { expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) }) + it('clears launch config when an agent startup spawn produces no PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const launchConfig = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + } as StoreState + + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + startup: { + command: 'codex', + launchConfig, + launchToken: 'launch-token-1', + launchAgent: 'codex' + } + }) as never + ) + try { + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(mockStoreState.registerAgentLaunchConfig).toHaveBeenCalledWith(paneKey, launchConfig, { + agentType: 'codex', + launchToken: 'launch-token-1', + tabId: 'tab-1', + leafId: LEAF_1 + }) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) + + const registerOrder = mockStoreState.registerAgentLaunchConfig.mock.invocationCallOrder[0] + const clearOrder = mockStoreState.clearAgentLaunchConfig.mock.invocationCallOrder[0] + expect(registerOrder).toBeDefined() + expect(clearOrder).toBeDefined() + expect(registerOrder!).toBeLessThan(clearOrder!) + } finally { + binding.dispose() + } + }) + it('prefers live-entry launch config for pane cold restore when status survived PTY loss', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') @@ -4732,6 +4786,48 @@ describe('connectPanePty', () => { binding.dispose() }) + it('keeps hidden Grok telemetry startup output parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { + command: 'wrapped-agent', + telemetry: { + agent_kind: 'grok', + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + it('keeps hidden bare Codex startup commands parsing briefly', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -4767,6 +4863,41 @@ describe('connectPanePty', () => { binding.dispose() }) + it('keeps hidden bare Grok startup commands parsing briefly', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ + isVisibleRef: { current: false }, + startup: { command: '/Users/me/.grok/bin/grok --permission-mode bypassPermissions' } + }) as never + ) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('\x1b]11;?\x1b\\startup frame\r\n') + + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b]11;?\x1b\\', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b]11;?\x1b\\startup frame\r\n', + expect.any(Function) + ) + + binding.dispose() + }) + it('skips arbitrary hidden startup output parsing', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -6025,6 +6156,7 @@ describe('connectPanePty', () => { const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< typeof vi.fn > + const signalPty = window.api.pty.signal as unknown as ReturnType const firstSnapshot = createDeferred<{ data: string cols: number @@ -6065,10 +6197,12 @@ describe('connectPanePty', () => { seq: hidden.length + visibleLive.length + hiddenAgain.length, rawLength: hiddenAgain.length }) + transport.resize.mockClear() + signalPty.mockClear() firstSnapshot.resolve({ data: 'snapshot-before-hidden-again\r\n', - cols: 120, - rows: 40, + cols: 100, + rows: 30, seq: hidden.length + visibleLive.length }) await flushAsyncTicks(20) @@ -6079,6 +6213,8 @@ describe('connectPanePty', () => { expect.any(Function) ) expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenAgain, expect.any(Function)) + expect(transport.resize).not.toHaveBeenCalled() + expect(signalPty).not.toHaveBeenCalledWith('pty-id', 'SIGWINCH') ;(deps.isVisibleRef as { current: boolean }).current = true visibilityState = 'visible' @@ -6139,6 +6275,84 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('does not signal SIGWINCH after hidden-backlog snapshot replay when dimensions are unchanged', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const signalPty = window.api.pty.signal as unknown as ReturnType + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 120, + rows: 40, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + transport.resize.mockClear() + signalPty.mockClear() + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) + expect(transport.resize).not.toHaveBeenCalledWith(120, 40) + expect(signalPty).not.toHaveBeenCalledWith('pty-id', 'SIGWINCH') + disposable.dispose() + }) + + it('does not forward terminal resizes while the pane is hidden', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const isVisibleRef = { current: false } + const deps = createDeps({ isVisibleRef }) + + const disposable = connectPanePty(pane as never, manager as never, deps as never) + const onResizeMock = pane.terminal.onResize as unknown as { + mock: { calls: [[(event: { cols: number; rows: number }) => void] | []] } + } + const resizeHandler = onResizeMock.mock.calls[0]?.[0] + if (!resizeHandler) { + throw new Error('Expected terminal resize handler to be registered') + } + + transport.resize.mockClear() + resizeHandler({ cols: 121, rows: 41 }) + + expect(transport.resize).not.toHaveBeenCalled() + + isVisibleRef.current = true + resizeHandler({ cols: 122, rows: 42 }) + + expect(transport.resize).toHaveBeenCalledWith(122, 42) + disposable.dispose() + }) + it('writes foreground chunks that are newer than the restored main snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5807981a5c5..4133374fa2d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -84,7 +84,7 @@ import { import { executeTerminalStartupCommandPaste } from './terminal-startup-command-paste' import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' -import { isCodexTerminalStartupCommand } from './terminal-startup-command-classifier' +import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' import { createCommandCodeOutputStatusDetector } from './command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' @@ -316,8 +316,8 @@ function shouldKeepHiddenStartupRendererQueriesLive( startup: PtyConnectionDeps['startup'] ): boolean { return ( - startup?.telemetry?.agent_kind === 'codex' || - isCodexTerminalStartupCommand(startup?.command ?? '') + Boolean(startup?.telemetry?.agent_kind && startup.telemetry.agent_kind !== 'other') || + isKnownTuiAgentTerminalStartupCommand(startup?.command ?? '') ) } @@ -813,6 +813,7 @@ export function connectPanePty( let pendingTerminalBellNotification = false let reattachIdleAgentCursorResetTimer: ReturnType | null = null let synchronizedForegroundOutputActive = false + let suppressSnapshotReplayPtyResize = false // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer // below so hidden-tab resets keep backlog-recovery callbacks and byte order. @@ -940,6 +941,9 @@ export function connectPanePty( leafId: pane.leafId }) } + const clearRegisteredStartupLaunchConfig = (): void => { + useAppStore.getState().clearAgentLaunchConfig(cacheKey) + } const pendingSpawnKey = cacheKey const neutralTerminalTitle = (): string => { const state = useAppStore.getState() @@ -1956,7 +1960,19 @@ export function connectPanePty( ) } + const isRendererPtyResizeAuthoritative = (): boolean => { + if (deps.isVisibleRef.current) { + return true + } + // Why: hidden-tab layout churn is not authoritative; visible resume + // owns correction, and hidden SIGWINCH can reset full-screen TUIs. + return false + } + const forwardPtyResize = (cols: number, rows: number): void => { + if (!isRendererPtyResizeAuthoritative()) { + return + } // Why: when a mobile-fit override is active OR mobile is currently the // driver of this PTY, the PTY is already at phone dims and any desktop // resize is wrong. Suppress resize forwarding to avoid spurious SIGWINCH @@ -1982,6 +1998,12 @@ export function connectPanePty( pane.container.addEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => { + if (suppressSnapshotReplayPtyResize) { + return + } + if (!isRendererPtyResizeAuthoritative()) { + return + } if (shouldSuppressDesktopPtyResize()) { return } @@ -2411,6 +2433,14 @@ export function connectPanePty( } if (resolvedPtyId) { clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) + } else if ( + paneStartup?.launchConfig || + (startupOverride && 'launchConfig' in startupOverride) + ) { + // Why: delayed draft/follow-up delivery keys off this launch + // registry. If spawn produced no PTY, the launch is no longer a + // viable delivery target and must not wait for a future pane. + clearRegisteredStartupLaunchConfig() } const gen = await preSignalPromise if (typeof gen === 'number' && resolvedPtyId) { @@ -2427,6 +2457,9 @@ export function connectPanePty( return resolvedPtyId }) .catch(async () => { + if (paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride)) { + clearRegisteredStartupLaunchConfig() + } const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -3115,17 +3148,27 @@ export function connectPanePty( seq?: number }): void { const scrollState = captureScrollStateForSnapshotReplay() - discardTerminalOutput(pane.terminal) - if ( + const colsBeforeReplay = pane.terminal.cols + const rowsBeforeReplay = pane.terminal.rows + const hasSnapshotDimensions = Number.isFinite(snapshot.cols) && Number.isFinite(snapshot.rows) && snapshot.cols > 0 && - snapshot.rows > 0 && + snapshot.rows > 0 + discardTerminalOutput(pane.terminal) + if ( + hasSnapshotDimensions && (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) ) { // Why: serialized terminal snapshots encode layout at their source // dimensions. Replay at those dimensions first, then fit back below. - pane.terminal.resize(snapshot.cols, snapshot.rows) + // This xterm-only resize must not SIGWINCH the live TUI. + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshot.cols, snapshot.rows) + } finally { + suppressSnapshotReplayPtyResize = false + } } writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(snapshot.data) @@ -3135,9 +3178,16 @@ export function connectPanePty( const currentPtyId = transport.getPtyId() if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { safeFit(pane) - transport.resize(pane.terminal.cols, pane.terminal.rows) - if (!isRemoteRuntimePtyId(currentPtyId)) { - window.api.pty.signal(currentPtyId, 'SIGWINCH') + const replayChangedDimensions = hasSnapshotDimensions + ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows + : pane.terminal.cols !== colsBeforeReplay || pane.terminal.rows !== rowsBeforeReplay + if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { + transport.resize(pane.terminal.cols, pane.terminal.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild + // their internal scroll viewport to the top on tab return. + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } } scheduleReattachIdleAgentCursorReset() } diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts index 375abf4d453..ddb842b66f1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts @@ -1,7 +1,12 @@ import type { ILink } from '@xterm/xterm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearRuntimeCompatibilityCacheForTests, + markRuntimeEnvironmentCompatible +} from '@/runtime/runtime-rpc-client' import { createTerminalHandleLinkProvider, + extractOrchestrationTaskLinks, extractTerminalHandleLinks, findTerminalHandleTarget, focusRendererTerminalHandle @@ -14,6 +19,9 @@ const mocks = vi.hoisted(() => ({ tabsByWorktree: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + runtimeAgentOrchestrationByPaneKey: {}, setActiveWorktree: vi.fn(), markWorktreeVisited: vi.fn(), setActiveView: vi.fn(), @@ -73,7 +81,11 @@ function setPlatform(userAgent: string): void { vi.stubGlobal('navigator', { userAgent }) } -async function collectLinks(rows: TestBufferLine[], bufferLineNumber = 1): Promise { +async function collectLinks( + rows: TestBufferLine[], + bufferLineNumber = 1, + runtimeEnvironmentId: string | null = null +): Promise { const terminal = { buffer: { active: { @@ -84,7 +96,7 @@ async function collectLinks(rows: TestBufferLine[], bufferLineNumber = 1): Promi } const provider = createTerminalHandleLinkProvider({ getTerminal: () => terminal as never, - getRuntimeEnvironmentId: () => null, + getRuntimeEnvironmentId: () => runtimeEnvironmentId, linkTooltip: { textContent: '', style: { display: '' } } as unknown as HTMLElement }) return await new Promise((resolve) => { @@ -130,6 +142,26 @@ describe('extractTerminalHandleLinks', () => { }) }) +describe('extractOrchestrationTaskLinks', () => { + it('detects task IDs from orchestration dispatch output', () => { + const line = 'Ran orca orchestration dispatch --task task_88f323f654c0 --to term_worker' + + expect(extractOrchestrationTaskLinks(line)).toEqual([ + { + taskId: 'task_88f323f654c0', + startIndex: 39, + endIndex: 56 + } + ]) + }) + + it('trims sentence punctuation without matching inside longer tokens', () => { + expect(extractOrchestrationTaskLinks('Open task_worker, not xtask_worker.')).toEqual([ + { taskId: 'task_worker', startIndex: 5, endIndex: 16 } + ]) + }) +}) + describe('findTerminalHandleTarget', () => { it('finds split-pane remote runtime handles from leaf PTY mappings', () => { expect( @@ -183,6 +215,9 @@ describe('focusRendererTerminalHandle', () => { mocks.storeState.terminalLayoutsByTabId = { 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } } + mocks.storeState.agentStatusByPaneKey = {} + mocks.storeState.retainedAgentsByPaneKey = {} + mocks.storeState.runtimeAgentOrchestrationByPaneKey = {} }) afterEach(() => { @@ -207,13 +242,27 @@ describe('createTerminalHandleLinkProvider', () => { mocks.storeState.tabsByWorktree = {} mocks.storeState.ptyIdsByTabId = {} mocks.storeState.terminalLayoutsByTabId = {} + mocks.storeState.agentStatusByPaneKey = {} + mocks.storeState.retainedAgentsByPaneKey = {} + mocks.storeState.runtimeAgentOrchestrationByPaneKey = {} vi.stubGlobal('window', { api: { runtime: { - call: vi.fn().mockResolvedValue({ - ok: true, - result: { focus: { handle: 'term_worker', tabId: 'tab-1', worktreeId: 'wt-1' } } + call: vi.fn().mockImplementation(({ method }) => { + if (method === 'orchestration.dispatchShow') { + return Promise.resolve({ + ok: true, + result: { dispatch: { assignee_handle: 'term_worker' } } + }) + } + return Promise.resolve({ + ok: true, + result: { focus: { handle: 'term_worker', tabId: 'tab-1', worktreeId: 'wt-1' } } + }) }) + }, + runtimeEnvironments: { + call: vi.fn() } } }) @@ -221,6 +270,7 @@ describe('createTerminalHandleLinkProvider', () => { afterEach(() => { vi.unstubAllGlobals() + clearRuntimeCompatibilityCacheForTests() }) it('provides wrapped terminal handle links and focuses through runtime on activation', async () => { @@ -244,6 +294,133 @@ describe('createTerminalHandleLinkProvider', () => { }) }) + it('provides wrapped task links and focuses their dispatched terminal through runtime', async () => { + const rows = [makeBufferLine('Task: task_work'), makeBufferLine('er', { isWrapped: true })] + const links = await collectLinks(rows, 1) + + expect(links.map((link) => link.text)).toEqual(['task_worker']) + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + + expect(window.api.runtime.call).toHaveBeenNthCalledWith(1, { + method: 'orchestration.dispatchShow', + params: { task: 'task_worker' } + }) + expect(window.api.runtime.call).toHaveBeenNthCalledWith(2, { + method: 'terminal.focus', + params: { terminal: 'term_worker' } + }) + }) + + it('returns task and terminal links in line order', async () => { + const links = await collectLinks([ + makeBufferLine('Dispatch --task task_worker --to term_worker') + ]) + + expect(links.map((link) => link.text)).toEqual(['task_worker', 'term_worker']) + }) + + it('uses runtime dispatch for task links instead of stale renderer task snapshots', async () => { + mocks.storeState.tabsByWorktree = { + 'wt-1': [ + { + id: 'tab-stale', + worktreeId: 'wt-1', + ptyId: 'term_stale', + title: 'Stale worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.agentStatusByPaneKey = { + 'tab-stale:11111111-1111-4111-8111-111111111111': { + paneKey: 'tab-stale:11111111-1111-4111-8111-111111111111', + tabId: 'tab-stale', + worktreeId: 'wt-1', + orchestration: { + taskId: 'task_worker', + dispatchId: 'ctx_stale' + } + } + } + const links = await collectLinks([makeBufferLine('Task: task_worker')]) + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + expect(window.api.runtime.call).toHaveBeenNthCalledWith(1, { + method: 'orchestration.dispatchShow', + params: { task: 'task_worker' } + }) + expect(window.api.runtime.call).toHaveBeenNthCalledWith(2, { + method: 'terminal.focus', + params: { terminal: 'term_worker' } + }) + }) + + it('focuses task links through their owning runtime environment', async () => { + markRuntimeEnvironmentCompatible('env-1') + window.api.runtimeEnvironments.call = vi.fn().mockImplementation(({ method }) => { + if (method === 'orchestration.dispatchShow') { + return Promise.resolve({ + ok: true, + result: { dispatch: { assignee_handle: 'term_remote' } } + }) + } + return Promise.resolve({ + ok: true, + result: { focus: { handle: 'term_remote', tabId: 'tab-remote', worktreeId: 'wt-remote' } } + }) + }) + const links = await collectLinks([makeBufferLine('Task: task_remote')], 1, 'env-1') + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(window.api.runtime.call).not.toHaveBeenCalled() + expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'orchestration.dispatchShow', + params: { task: 'task_remote' }, + timeoutMs: undefined + }) + expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'terminal.focus', + params: { terminal: 'term_remote' }, + timeoutMs: undefined + }) + }) + it('contains runtime focus failures for stale terminal handles', async () => { const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) window.api.runtime.call = vi.fn().mockRejectedValue(new Error('terminal not found')) diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index ec0f30d4245..d692e68b897 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -6,6 +6,14 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream' import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' +import { + extractOrchestrationTaskLinks, + focusRuntimeOrchestrationTask, + ORCHESTRATION_TASK_PREFIX +} from './terminal-orchestration-task-links' + +export { extractOrchestrationTaskLinks } from './terminal-orchestration-task-links' +export type { ParsedOrchestrationTaskLink } from './terminal-orchestration-task-links' export type ParsedTerminalHandleLink = { handle: string @@ -35,39 +43,50 @@ const MAX_TERMINAL_HANDLE_BODY_LENGTH = 128 const TERMINAL_HANDLE_BOUNDARY_CHAR = /[A-Za-z0-9_-]/ export function extractTerminalHandleLinks(lineText: string): ParsedTerminalHandleLink[] { - if (!lineText.includes(TERMINAL_HANDLE_PREFIX)) { + return extractPrefixedTokenLinks(lineText, TERMINAL_HANDLE_PREFIX).map((link) => ({ + handle: link.token, + startIndex: link.startIndex, + endIndex: link.endIndex + })) +} + +function extractPrefixedTokenLinks( + lineText: string, + prefix: string +): { token: string; startIndex: number; endIndex: number }[] { + if (!lineText.includes(prefix)) { return [] } - const links: ParsedTerminalHandleLink[] = [] + const links: { token: string; startIndex: number; endIndex: number }[] = [] let searchStart = 0 while (searchStart < lineText.length) { - const startIndex = lineText.indexOf(TERMINAL_HANDLE_PREFIX, searchStart) + const startIndex = lineText.indexOf(prefix, searchStart) if (startIndex === -1) { break } - const bodyStart = startIndex + TERMINAL_HANDLE_PREFIX.length - const tokenEnd = findTerminalHandleTokenEnd(lineText, bodyStart) + const bodyStart = startIndex + prefix.length + const tokenEnd = findPrefixedTokenEnd(lineText, bodyStart) searchStart = Math.max(tokenEnd, bodyStart + 1) const bodyLength = tokenEnd - bodyStart if (bodyLength === 0 || bodyLength > MAX_TERMINAL_HANDLE_BODY_LENGTH) { continue } - const handle = lineText.slice(startIndex, tokenEnd) + const token = lineText.slice(startIndex, tokenEnd) if ( TERMINAL_HANDLE_BOUNDARY_CHAR.test(lineText[startIndex - 1] ?? '') || TERMINAL_HANDLE_BOUNDARY_CHAR.test(lineText[tokenEnd] ?? '') ) { continue } - links.push({ handle, startIndex, endIndex: tokenEnd }) + links.push({ token, startIndex, endIndex: tokenEnd }) } return links } -function findTerminalHandleTokenEnd(lineText: string, startIndex: number): number { +function findPrefixedTokenEnd(lineText: string, startIndex: number): number { let index = startIndex while (index < lineText.length && TERMINAL_HANDLE_BOUNDARY_CHAR.test(lineText[index])) { index += 1 @@ -131,12 +150,29 @@ export function createTerminalHandleLinkProvider( return } const logicalLine = buildWrappedLogicalLine(terminal.buffer.active, bufferLineNumber) - if (!logicalLine || !logicalLine.text.includes('term_')) { + if ( + !logicalLine || + (!logicalLine.text.includes(TERMINAL_HANDLE_PREFIX) && + !logicalLine.text.includes(ORCHESTRATION_TASK_PREFIX)) + ) { callback(undefined) return } - const links = extractTerminalHandleLinks(logicalLine.text) + const terminalLinks = extractTerminalHandleLinks(logicalLine.text).map((parsed) => ({ + kind: 'terminal' as const, + text: parsed.handle, + startIndex: parsed.startIndex, + endIndex: parsed.endIndex + })) + const taskLinks = extractOrchestrationTaskLinks(logicalLine.text).map((parsed) => ({ + kind: 'task' as const, + text: parsed.taskId, + startIndex: parsed.startIndex, + endIndex: parsed.endIndex + })) + const links = [...terminalLinks, ...taskLinks] + .sort((a, b) => a.startIndex - b.startIndex) .map((parsed): ILink | null => { const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex) if (!range) { @@ -144,24 +180,17 @@ export function createTerminalHandleLinkProvider( } return { range, - text: parsed.handle, + text: parsed.text, activate: (event) => { if (!isTerminalHandleLinkActivation(event)) { return } event?.preventDefault() - if (!focusRendererTerminalHandle(parsed.handle)) { - void focusRuntimeTerminalHandle( - parsed.handle, - deps.getRuntimeEnvironmentId() - ).catch((error: unknown) => { - console.warn('[terminal-handle-link] focus failed:', error) - }) - } + void activateParsedLink(parsed, deps.getRuntimeEnvironmentId()) terminal.clearSelection() }, hover: () => { - deps.linkTooltip.textContent = `${parsed.handle} (${getTerminalHandleFocusHint()})` + deps.linkTooltip.textContent = `${parsed.text} (${getTerminalHandleFocusHint()})` deps.linkTooltip.style.display = '' }, leave: () => { @@ -176,6 +205,25 @@ export function createTerminalHandleLinkProvider( } } +async function activateParsedLink( + parsed: { kind: 'terminal' | 'task'; text: string }, + runtimeEnvironmentId: string | null +): Promise { + try { + if (parsed.kind === 'terminal') { + if (!focusRendererTerminalHandle(parsed.text)) { + await focusRuntimeTerminalHandle(parsed.text, runtimeEnvironmentId) + } + return + } + // Why: a task can be retried onto a new dispatch; runtime DB is the + // authority for the latest terminal assigned to a stable task ID. + await focusRuntimeOrchestrationTask(parsed.text, runtimeEnvironmentId) + } catch (error: unknown) { + console.warn('[terminal-handle-link] focus failed:', error) + } +} + function ptyIdMatchesTerminalHandle(ptyId: string, handle: string): boolean { return ptyId === handle || getRemoteRuntimeTerminalHandle(ptyId) === handle } diff --git a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts new file mode 100644 index 00000000000..99acf41f12c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts @@ -0,0 +1,73 @@ +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' + +export type ParsedOrchestrationTaskLink = { + taskId: string + startIndex: number + endIndex: number +} + +export const ORCHESTRATION_TASK_PREFIX = 'task_' + +const MAX_ORCHESTRATION_TASK_BODY_LENGTH = 128 +const ORCHESTRATION_TASK_BOUNDARY_CHAR = /[A-Za-z0-9_-]/ + +export function extractOrchestrationTaskLinks(lineText: string): ParsedOrchestrationTaskLink[] { + if (!lineText.includes(ORCHESTRATION_TASK_PREFIX)) { + return [] + } + + const links: ParsedOrchestrationTaskLink[] = [] + let searchStart = 0 + while (searchStart < lineText.length) { + const startIndex = lineText.indexOf(ORCHESTRATION_TASK_PREFIX, searchStart) + if (startIndex === -1) { + break + } + + const bodyStart = startIndex + ORCHESTRATION_TASK_PREFIX.length + const tokenEnd = findOrchestrationTaskTokenEnd(lineText, bodyStart) + searchStart = Math.max(tokenEnd, bodyStart + 1) + const bodyLength = tokenEnd - bodyStart + if (bodyLength === 0 || bodyLength > MAX_ORCHESTRATION_TASK_BODY_LENGTH) { + continue + } + + const taskId = lineText.slice(startIndex, tokenEnd) + if ( + ORCHESTRATION_TASK_BOUNDARY_CHAR.test(lineText[startIndex - 1] ?? '') || + ORCHESTRATION_TASK_BOUNDARY_CHAR.test(lineText[tokenEnd] ?? '') + ) { + continue + } + links.push({ taskId, startIndex, endIndex: tokenEnd }) + } + return links +} + +export async function focusRuntimeOrchestrationTask( + taskId: string, + runtimeEnvironmentId: string | null +): Promise { + const environmentId = runtimeEnvironmentId?.trim() + const target = environmentId + ? ({ kind: 'environment', environmentId } as const) + : ({ kind: 'local' } as const) + const result = await callRuntimeRpc<{ + dispatch: { assignee_handle?: string | null } | null + }>(target, 'orchestration.dispatchShow', { task: taskId }) + const terminal = result.dispatch?.assignee_handle?.trim() + if (!terminal) { + throw new Error(`No dispatched terminal for orchestration task ${taskId}`) + } + // Why: task IDs are stable orchestration DB records, but terminal.focus owns + // the app-side navigation contract for local and SSH runtime terminals. + await callRuntimeRpc(target, 'terminal.focus', { terminal }) +} + +function findOrchestrationTaskTokenEnd(lineText: string, startIndex: number): number { + let index = startIndex + while (index < lineText.length && ORCHESTRATION_TASK_BOUNDARY_CHAR.test(lineText[index])) { + index += 1 + } + return index +} diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts index c6851a92066..3444bf412d6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getTerminalStartupCommandToken, isCodexTerminalStartupCommand, + isKnownTuiAgentTerminalStartupCommand, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS } from './terminal-startup-command-classifier' @@ -22,6 +23,7 @@ describe('terminal startup command classifier', () => { expect(getTerminalStartupCommandToken(command)).toBe('codex') expect(isCodexTerminalStartupCommand(command)).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(true) expect(getRegexWhitespaceSplitCalls(split)).toHaveLength(0) }) @@ -30,6 +32,7 @@ describe('terminal startup command classifier', () => { expect(getTerminalStartupCommandToken(command)).toBe('C:\\Program Files\\Orca\\codex.cmd') expect(isCodexTerminalStartupCommand(command)).toBe(true) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(true) }) it('recognizes POSIX Codex wrapper names', () => { @@ -37,6 +40,18 @@ describe('terminal startup command classifier', () => { expect(isCodexTerminalStartupCommand('/usr/local/bin/not-codex --continue')).toBe(false) }) + it('recognizes non-Codex Orca agent startup commands', () => { + expect(isKnownTuiAgentTerminalStartupCommand('grok --permission-mode bypassPermissions')).toBe( + true + ) + expect(isKnownTuiAgentTerminalStartupCommand('/Users/me/.grok/bin/grok --resume abc')).toBe( + true + ) + expect(isKnownTuiAgentTerminalStartupCommand('/usr/local/bin/not-grok --resume abc')).toBe( + false + ) + }) + it('bounds pathological single-token startup commands', () => { const split = vi.spyOn(String.prototype, 'split') const command = 'codex'.repeat(TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) @@ -45,6 +60,7 @@ describe('terminal startup command classifier', () => { TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS ) expect(isCodexTerminalStartupCommand(command)).toBe(false) + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(false) expect(getRegexWhitespaceSplitCalls(split)).toHaveLength(0) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts index 9bfd858185c..f171efc01cc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts @@ -1,7 +1,24 @@ +import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' + const TERMINAL_STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i // Why: startup commands can carry pasted scripts; classifier work should stay bounded. export const TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS = 4096 +const KNOWN_TUI_AGENT_EXECUTABLES = new Set() + +for (const config of Object.values(TUI_AGENT_CONFIG)) { + for (const candidate of [ + config.detectCmd, + config.expectedProcess, + ...getTuiAgentDetectCommands(config) + ]) { + const executable = normalizeTerminalStartupCommandExecutableName(candidate) + if (executable) { + KNOWN_TUI_AGENT_EXECUTABLES.add(executable) + } + } +} + export function getTerminalStartupCommandToken(command: string): string { const scanLimit = Math.min(command.length, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) let index = 0 @@ -38,10 +55,23 @@ export function isCodexTerminalStartupCommand(command: string): boolean { return executable === 'codex' || executable.startsWith('codex-') } +export function isKnownTuiAgentTerminalStartupCommand(command: string): boolean { + const executable = getTerminalStartupCommandExecutableName(command) + return ( + KNOWN_TUI_AGENT_EXECUTABLES.has(executable) || + executable.startsWith('codex-') || + executable.startsWith('grok-') + ) +} + function getTerminalStartupCommandExecutableName(command: string): string { const token = getTerminalStartupCommandToken(command) const segmentStart = getTerminalStartupCommandPathSegmentStart(token) - return token.slice(segmentStart).toLowerCase().replace(TERMINAL_STARTUP_COMMAND_EXTENSION_RE, '') + return normalizeTerminalStartupCommandExecutableName(token.slice(segmentStart)) +} + +function normalizeTerminalStartupCommandExecutableName(executable: string): string { + return executable.toLowerCase().replace(TERMINAL_STARTUP_COMMAND_EXTENSION_RE, '') } function getTerminalStartupCommandPathSegmentStart(token: string): number { diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts new file mode 100644 index 00000000000..87db96c3a22 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -0,0 +1,150 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { ScrollState } from '@/lib/pane-manager/pane-manager-types' +import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' +import { + flushTerminalOutput, + requestTerminalBacklogRecovery +} from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' +import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' + +const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 + +export type TerminalHiddenReason = 'surface' | 'tab' + +type ResumeTerminalVisibilityArgs = { + manager: PaneManager + isActive: boolean + wasVisible: boolean + shouldUseLightTabResume: boolean + captureViewportPositions: (useRememberedSnapshots: boolean) => Map + withSuppressedScrollTracking: (callback: () => void) => void +} + +type HideTerminalVisibilityArgs = { + manager: PaneManager + wasVisible: boolean + wasWorktreeActive: boolean + isWorktreeActive: boolean + hasCompletedVisibleResume: boolean + captureViewportPositions: (useRememberedSnapshots: boolean) => Map +} + +type HideTerminalVisibilityResult = { + hiddenReason: TerminalHiddenReason | null + renderingSuspended: boolean +} + +export function resumeTerminalVisibility({ + manager, + isActive, + wasVisible, + shouldUseLightTabResume, + captureViewportPositions, + withSuppressedScrollTracking +}: ResumeTerminalVisibilityArgs): void { + // Why: WebGL resume can disturb xterm's viewport bookkeeping before the + // post-resume fit runs. Capture numeric viewport positions first; the + // restore path avoids content matching so duplicate agent log lines do + // not jump to the wrong history entry. + const viewportPositions = captureViewportPositions(!wasVisible) + withSuppressedScrollTracking(() => { + if (shouldUseLightTabResume) { + // Why: intra-worktree tab switches only toggle the overlay. Keeping + // synchronous drain and atlas rebuilds off this path avoids racing the + // overlay's delayed geometry fit. Still request hidden-output recovery: + // agent TUIs can suppress hidden bytes until the pane is foregrounded. + requestLightTabBacklogRecovery(manager) + if (isActive) { + focusActivePane(manager) + } + } else { + resumeTerminalVisibilityHeavy(manager, isActive) + } + restoreTerminalViewportPositions(manager, viewportPositions) + if (!shouldUseLightTabResume) { + // Why: this clear wipes the glyph atlas shared with other same-config + // terminals; the global reset rebuilds their render models too. + resetAllTerminalWebglAtlases() + } + }) +} + +export function hideTerminalVisibility({ + manager, + wasVisible, + wasWorktreeActive, + isWorktreeActive, + hasCompletedVisibleResume, + captureViewportPositions +}: HideTerminalVisibilityArgs): HideTerminalVisibilityResult { + const surfaceBecameHidden = wasWorktreeActive && !isWorktreeActive + if (wasVisible) { + // Why: hidden DOM/layout churn can mutate xterm's viewport before the + // pane becomes visible again. Preserve the last visible position. + captureViewportPositions(false) + } + if (!isWorktreeActive && (wasVisible || surfaceBecameHidden)) { + // Suspend WebGL when going hidden. xterm.write() continues to land in + // the (now DOM-renderer-fallback or paused-canvas) terminal; the + // suspend is purely a GPU resource decision. + manager.suspendRendering() + return { hiddenReason: 'surface', renderingSuspended: true } + } + if (!hasCompletedVisibleResume && wasVisible && wasWorktreeActive && isWorktreeActive) { + // Why: the visibility hook starts wasVisible=true so terminal tabs that + // first mount hidden still release WebGL contexts instead of exhausting + // Chromium's small context budget. + manager.suspendRendering() + return { hiddenReason: 'tab', renderingSuspended: true } + } + if (wasVisible && isWorktreeActive) { + return { hiddenReason: 'tab', renderingSuspended: false } + } + if (!isWorktreeActive) { + return { hiddenReason: 'surface', renderingSuspended: false } + } + return { hiddenReason: null, renderingSuspended: false } +} + +function requestLightTabBacklogRecovery(manager: PaneManager): void { + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + } +} + +function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): void { + // Why: hidden panes can accumulate large PTY bursts while Chromium is + // occluded. Drain a bounded slice before fitting; the scheduler keeps + // ordering and continues the rest asynchronously so return-to-app does + // not beachball behind an entire backlog. + for (const pane of manager.getPanes()) { + requestTerminalBacklogRecovery(pane.terminal) + flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) + } + // Resume WebGL immediately so the terminal shows its last-known state + // on the first painted frame. macOS context creation is ~5 ms; on + // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume + // would paint a stretched DOM-fallback flash, which is worse UX. + manager.resumeRendering() + // Single fit on resume. Background bytes have been pushed into xterm + // above, so this fit only absorbs container dimension changes that + // happened while hidden (e.g. sidebar toggle on another worktree). + if (isActive) { + fitAndFocusPanes(manager) + } else { + fitPanes(manager) + } +} + +function restoreTerminalViewportPositions( + manager: PaneManager, + viewportPositions: Map +): void { + for (const pane of manager.getPanes()) { + const position = viewportPositions.get(pane.id) + if (position) { + restoreScrollStateAfterLayout(pane.terminal, position) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 82a185783ec..df9f4ba45fc 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ captureScrollState: vi.fn(), fitAndFocusPanes: vi.fn(), fitPanes: vi.fn(), + focusActivePane: vi.fn(), flushTerminalOutput: vi.fn(), getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), @@ -58,7 +59,8 @@ vi.mock('react', async (importOriginal) => { vi.mock('./pane-helpers', () => ({ fitAndFocusPanes: mocks.fitAndFocusPanes, - fitPanes: mocks.fitPanes + fitPanes: mocks.fitPanes, + focusActivePane: mocks.focusActivePane })) vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ @@ -113,6 +115,7 @@ function useMountForFileDrop( cwd?: string isActive?: boolean isVisible?: boolean + isWorktreeActive?: boolean isSyncFitEnabled?: boolean paneCount?: number } = {} @@ -150,6 +153,7 @@ function useMountForFileDrop( cwd: options.cwd, isActive: options.isActive ?? true, isVisible: options.isVisible ?? true, + isWorktreeActive: options.isWorktreeActive ?? options.isVisible ?? true, isSyncFitEnabled: options.isSyncFitEnabled ?? options.isVisible ?? true, paneCount: options.paneCount ?? 0, managerRef: { current: manager as never }, @@ -278,6 +282,257 @@ describe('useTerminalPaneGlobalEffects', () => { expect(isVisibleRef.current).toBe(true) }) + it('uses a light resume for tab switches while the worktree stays active', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + manager.suspendRendering.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.fitPanes.mockClear() + mocks.focusActivePane.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false + }) + + expect(manager.suspendRendering).not.toHaveBeenCalled() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() + expect(manager.resumeRendering).not.toHaveBeenCalled() + expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) + }) + + it('keeps visible active-state updates on the light resume path', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: true + }) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.fitPanes.mockClear() + mocks.focusActivePane.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).not.toHaveBeenCalled() + expect(manager.resumeRendering).not.toHaveBeenCalled() + expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) + }) + + it('suspends rendering when a terminal tab first mounts hidden', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + isWorktreeActive: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false + }) + + expect(manager.suspendRendering).toHaveBeenCalledTimes(1) + + manager.suspendRendering.mockClear() + manager.resumeRendering.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 256 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + }) + + it('suspends a tab-hidden terminal when its worktree surface becomes hidden', () => { + const terminal = { name: 'terminal-a' } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]), + resumeRendering: vi.fn(), + resetWebglTextureAtlases: vi.fn(), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + registerManagerForReset(manager) + const baseArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + paneCount: 1, + isSyncFitEnabled: true, + toggleExpandPane: vi.fn() + } + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true, + isWorktreeActive: true + }) + + manager.suspendRendering.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false, + isWorktreeActive: true + }) + expect(manager.suspendRendering).not.toHaveBeenCalled() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: false, + isVisible: false, + isWorktreeActive: false + }) + + expect(manager.suspendRendering).toHaveBeenCalledTimes(1) + + manager.resumeRendering.mockClear() + manager.resetWebglTextureAtlases.mockClear() + mocks.fitAndFocusPanes.mockClear() + mocks.flushTerminalOutput.mockClear() + mocks.requestTerminalBacklogRecovery.mockClear() + + beginHookRender() + useTerminalPaneGlobalEffects({ + ...baseArgs, + isActive: true, + isVisible: true, + isWorktreeActive: true + }) + + expect(mocks.requestTerminalBacklogRecovery).toHaveBeenCalledWith(terminal) + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { maxChars: 256 * 1024 }) + expect(manager.resumeRendering).toHaveBeenCalledTimes(1) + expect(mocks.fitAndFocusPanes).toHaveBeenCalledWith(manager) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + }) + it('reports the active local PTY to the main output scheduler', () => { const manager = { getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]), diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 5fc62839abc..23a03049dc1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -8,22 +8,19 @@ import { } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' -import { fitAndFocusPanes, fitPanes } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { handleTerminalFileDrop } from './terminal-drop-handler' -import { - flushTerminalOutput, - requestTerminalBacklogRecovery -} from '@/lib/pane-manager/pane-terminal-output-scheduler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' import { surfaceStaleAgentRow } from './stale-agent-row' import { useAppStore } from '@/store' -import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' import { useTerminalScrollVisibilityMemory } from './use-terminal-scroll-visibility-memory' import { useTerminalContainerFitSync } from './use-terminal-container-fit-sync' import { handleTerminalProgrammaticTextPaste } from './terminal-programmatic-text-paste' - -const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 +import { + hideTerminalVisibility, + resumeTerminalVisibility, + type TerminalHiddenReason +} from './terminal-visibility-resume' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -31,6 +28,7 @@ type UseTerminalPaneGlobalEffectsArgs = { cwd?: string isActive: boolean isVisible: boolean + isWorktreeActive?: boolean isSyncFitEnabled: boolean paneCount: number managerRef: React.RefObject @@ -47,6 +45,7 @@ export function useTerminalPaneGlobalEffects({ cwd, isActive, isVisible, + isWorktreeActive = isVisible, isSyncFitEnabled, paneCount, managerRef, @@ -65,6 +64,10 @@ export function useTerminalPaneGlobalEffects({ // otherwise leak WebGL contexts — openTerminal() unconditionally creates // one — and exhaust Chromium's ~8-context budget across worktrees. const wasVisibleRef = useRef(true) + const wasWorktreeActiveRef = useRef(isWorktreeActive) + const hasCompletedVisibleResumeRef = useRef(false) + const renderingSuspendedByVisibilityRef = useRef(false) + const hiddenReasonRef = useRef(null) const { captureViewportPositions, withSuppressedScrollTracking, @@ -83,61 +86,47 @@ export function useTerminalPaneGlobalEffects({ if (!manager) { return } + const wasVisible = wasVisibleRef.current + const wasWorktreeActive = wasWorktreeActiveRef.current isActiveRef.current = isActive isVisibleRef.current = isVisible if (isVisible) { - // Why: WebGL resume can disturb xterm's viewport bookkeeping before the - // post-resume fit runs. Capture numeric viewport positions first; the - // restore path avoids content matching so duplicate agent log lines do - // not jump to the wrong history entry. - const viewportPositions = captureViewportPositions(!wasVisibleRef.current) - withSuppressedScrollTracking(() => { - // Why: hidden panes can accumulate large PTY bursts while Chromium is - // occluded. Drain a bounded slice before fitting; the scheduler keeps - // ordering and continues the rest asynchronously so return-to-app does - // not beachball behind an entire backlog. - for (const pane of manager.getPanes()) { - requestTerminalBacklogRecovery(pane.terminal) - flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) - } - // Resume WebGL immediately so the terminal shows its last-known state - // on the first painted frame. macOS context creation is ~5 ms; on - // Windows (ANGLE → D3D11) it can be 100–500 ms but a deferred resume - // would paint a stretched DOM-fallback flash, which is worse UX. - manager.resumeRendering() - // Single fit on resume. Background bytes have been pushed into xterm - // above, so this fit only absorbs container dimension changes that - // happened while hidden (e.g. sidebar toggle on another worktree). - if (isActive) { - fitAndFocusPanes(manager) - } else { - fitPanes(manager) - } - for (const pane of manager.getPanes()) { - const position = viewportPositions.get(pane.id) - if (position) { - restoreScrollStateAfterLayout(pane.terminal, position) - } - } - // Why: this clear wipes the glyph atlas shared with other same-config - // terminals; the global reset rebuilds their render models too. - resetAllTerminalWebglAtlases() + const shouldUseLightTabResume = + isWorktreeActive && + hasCompletedVisibleResumeRef.current && + !renderingSuspendedByVisibilityRef.current && + (wasVisible || hiddenReasonRef.current === 'tab') + resumeTerminalVisibility({ + manager, + isActive, + wasVisible, + shouldUseLightTabResume, + captureViewportPositions, + withSuppressedScrollTracking }) + renderingSuspendedByVisibilityRef.current = false wasVisibleRef.current = true + wasWorktreeActiveRef.current = isWorktreeActive + hasCompletedVisibleResumeRef.current = true + hiddenReasonRef.current = null applyPendingFollowOutputRequests() return - } else if (wasVisibleRef.current) { - // Why: hidden DOM/layout churn can mutate xterm's viewport before the - // pane becomes visible again. Preserve the last visible position. - captureViewportPositions(false) - // Suspend WebGL when going hidden. xterm.write() continues to land in - // the (now DOM-renderer-fallback or paused-canvas) terminal; the - // suspend is purely a GPU resource decision. - manager.suspendRendering() + } else { + const hiddenState = hideTerminalVisibility({ + manager, + wasVisible, + wasWorktreeActive, + isWorktreeActive, + hasCompletedVisibleResume: hasCompletedVisibleResumeRef.current, + captureViewportPositions + }) + renderingSuspendedByVisibilityRef.current = hiddenState.renderingSuspended + hiddenReasonRef.current = hiddenState.hiddenReason } wasVisibleRef.current = false + wasWorktreeActiveRef.current = isWorktreeActive // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isActive, isVisible]) + }, [isActive, isVisible, isWorktreeActive]) useEffect(() => { if (!isVisible) { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 8b89522a0de..586acdab728 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -9,6 +9,7 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { getAgentCatalog } from '@/lib/agent-catalog' +import { createBrowserUuid } from '@/lib/browser-uuid' import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink, @@ -257,6 +258,13 @@ export type ComposerCardProps = { * rather than used as the base for a new branch. */ reuseSelectedBranch: boolean onReuseSelectedBranchChange: (next: boolean) => void + /** Whether the "create multiple" toggle is shown — worktree (git) targets + * only; folder-workspace targets create-and-close as before. */ + showCreateMultiple: boolean + /** When on, the modal stays open after each create and resets identity fields + * so the user can create several worktrees in a row. */ + createMultiple: boolean + onCreateMultipleChange: (next: boolean) => void agentPrompt: string onAgentPromptChange: (value: string) => void /** Rendered issueCommand template to preview inside the empty prompt @@ -895,6 +903,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null) const [creating, setCreating] = useState(false) const [createError, setCreateError] = useState(null) + // Why: when checked, a successful worktree create keeps the modal open and + // resets identity fields so the user can queue another worktree without + // reopening. Defaults off; the modal unmounts on close, so reopening always + // starts unchecked. + const [createMultiple, setCreateMultiple] = useState(false) const [advancedOpen, setAdvancedOpen] = useState( persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false ) @@ -3119,6 +3132,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } : undefined const backendSpawnedStartup = result.startupTerminal?.spawned === true + if (startupPlan && !backendSpawnedStartup && !startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this + // queued startup, so both halves share one renderer-session token. + startupPlan.launchToken = createBrowserUuid() + } const activation = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto', setup: result.setup, @@ -3130,6 +3148,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, + ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: tuiAgent, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } @@ -3222,6 +3241,37 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS submitFolderTarget ]) + const resetForNextCreate = useCallback(() => { + // Why: with "create multiple" on, clear identity fields after each create so + // the next worktree starts clean. Context (repo, base branch, agent, project + // group) is intentionally retained for fast sequential creation. The + // PR-pick-derived refs (compare base, push target, branch override) are + // identity, not durable context, so they reset too — leaving them while the + // linked item is cleared would carry a half-set Start-from state (e.g. a + // silent fork push target) into the next worktree. + setName('') + lastAutoNameRef.current = '' + setAgentPrompt('') + setNote('') + setAttachmentPaths([]) + setLinkedWorkItem(null) + setLinkedIssue('') + setLinkedPR(null) + setLinkedGitLabIssue(null) + setLinkedGitLabMR(null) + setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + setCompareBaseRef(undefined) + setPushTarget(undefined) + setReuseSelectedBranch(false) + setStartFromResetHint(null) + setForkPushWarning(null) + setCreateError(null) + // Refocus the name field on the next frame (after the reset re-render) so the + // user can immediately type the next worktree name. + requestAnimationFrame(() => nameInputRef.current?.focus()) + }, []) + const submitQuick = useCallback( async (requestedAgent: TuiAgent | null): Promise => { if (isProjectGroupTarget) { @@ -3518,7 +3568,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note: trimmedNote, startupPlan, quickPrompt, - quickTelemetry + quickTelemetry, + ...(createMultiple ? { suppressTerminalFocusOnCompletion: true } : {}) } // Why: git fetch + `git worktree add` can take 10–15s; holding the modal @@ -3527,8 +3578,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (persistDraft) { clearNewWorkspaceDraft() } - onCreated?.() runBackgroundWorktreeCreation(request) + if (createMultiple) { + // Why: keep the modal open and reset identity so the user can queue + // another worktree right away; the creation above runs in the background. + resetForNextCreate() + } else { + onCreated?.() + } } catch (error) { const formattedError = formatWorkspaceCreateError(error) setCreateError(formattedError) @@ -3586,7 +3643,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setupConfig, setupPolicy, isProjectGroupTarget, - submitFolderTarget + submitFolderTarget, + createMultiple, + resetForNextCreate ] ) @@ -3636,6 +3695,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS smartNameSelection?.kind === 'branch', reuseSelectedBranch, onReuseSelectedBranchChange: handleReuseSelectedBranchChange, + // Why: the "create multiple" toggle only applies to worktree (git) targets; + // folder-workspace targets keep the create-and-close behavior. + showCreateMultiple: !isProjectGroupTarget, + createMultiple, + onCreateMultipleChange: setCreateMultiple, agentPrompt, onAgentPromptChange: setAgentPrompt, linkedOnlyTemplatePreview: shouldApplyLinkedOnlyTemplate ? linkedOnlyTemplatePrompt : null, diff --git a/src/renderer/src/hooks/useShortcutLabel.ts b/src/renderer/src/hooks/useShortcutLabel.ts index 6a92064f25b..9f15743ad40 100644 --- a/src/renderer/src/hooks/useShortcutLabel.ts +++ b/src/renderer/src/hooks/useShortcutLabel.ts @@ -32,6 +32,26 @@ export function useShortcutLabel(actionId: KeybindingActionId): string { return formatShortcutLabel(actionId, keybindings) } +// Why: returns null for unbound actions instead of the display sentinel +// 'Unassigned', so callers decide whether to render a hint without coupling +// UI logic to formatter copy (which may change or become localized). +export function formatOptionalShortcutLabel( + actionId: KeybindingActionId, + overrides?: KeybindingOverrides +): string | null { + const platform = getShortcutPlatform() + const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) + if (bindings.length === 0) { + return null + } + return formatKeybindingList(bindings, platform) +} + +export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null { + const keybindings = useAppStore((state) => state.keybindings) + return formatOptionalShortcutLabel(actionId, keybindings) +} + export function formatShortcutKeys( actionId: KeybindingActionId, overrides?: KeybindingOverrides diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts index e37a5705fec..a30b3f97ce5 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' function createReactHookHarness() { const refs: { current: unknown }[] = [] - const effects: { deps: readonly unknown[] | undefined }[] = [] + const effects: { + deps: readonly unknown[] | undefined + effect: () => void | (() => void) + }[] = [] let refIndex = 0 return { @@ -13,8 +16,8 @@ function createReactHookHarness() { effects, react: { useCallback: unknown>(callback: T): T => callback, - useLayoutEffect: (_effect: () => void | (() => void), deps?: readonly unknown[]) => { - effects.push({ deps }) + useLayoutEffect: (effect: () => void | (() => void), deps?: readonly unknown[]) => { + effects.push({ deps, effect }) }, useMemo: (factory: () => T): T => factory(), useRef: (initialValue: T): { current: T } => { @@ -69,4 +72,99 @@ describe('useVirtualizedScrollAnchor listener effect dependencies', () => { expect(initialDeps).toEqual([scrollElementRef, scrollOffsetRef]) expect(nextDeps).toEqual(initialDeps) }) + + it('keeps the target anchor while measured fallback restores a transitional window', async () => { + const harness = createReactHookHarness() + vi.doMock('react', () => harness.react) + const { useVirtualizedScrollAnchor } = await import('./useVirtualizedScrollAnchor') + + const anchorRef = { current: { key: 'row-1', offset: 3358 } } + const scrollElementRef = { + current: { + clientHeight: 880, + scrollHeight: 30_000, + scrollTop: 0, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + } + const scrollOffsetRef = { current: 0 } + const virtualizer = { + getVirtualItems: () => [ + { index: 8, start: 0, end: 30_000 }, + { index: 1, start: 758, end: 4512 } + ], + isScrolling: false, + scrollToIndex: vi.fn() + } + + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getRowKey: (row) => row, + rows: Array.from({ length: 9 }, (_, index) => `row-${index}`), + scrollElementRef, + scrollOffsetRef, + totalSize: 30_000, + virtualizer + } as never) + + harness.effects[1]?.effect() + + expect(scrollElementRef.current.scrollTop).toBe(4116) + expect(anchorRef.current).toEqual({ key: 'row-1', offset: 3358 }) + }) + + it('can ignore generic scroll anchor recording while preserving the saved anchor', async () => { + const harness = createReactHookHarness() + vi.doMock('react', () => harness.react) + const { useVirtualizedScrollAnchor } = await import('./useVirtualizedScrollAnchor') + + const capturedScrollHandler: { current: (() => void) | null } = { current: null } + const savedAnchor = { key: 'row-1', offset: 3358 } + const anchorRef = { current: savedAnchor } + const scrollElementRef = { + current: { + clientHeight: 880, + scrollHeight: 30_000, + scrollTop: 746, + addEventListener: vi.fn((eventName: string, handler: () => void) => { + if (eventName === 'scroll') { + capturedScrollHandler.current = handler + } + }), + removeEventListener: vi.fn() + } + } + const scrollOffsetRef = { current: 0 } + const virtualizer = { + getVirtualItems: () => [ + { index: 0, start: 0, end: 3_000 }, + { index: 1, start: 3_000, end: 7_000 } + ], + isScrolling: false, + scrollToIndex: vi.fn() + } + + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getRowKey: (row) => row, + recordAnchorOnScroll: false, + rows: ['row-0', 'row-1'], + scrollElementRef, + scrollOffsetRef, + totalSize: 30_000, + virtualizer + } as never) + + harness.effects[0]?.effect() + expect(capturedScrollHandler.current).not.toBeNull() + capturedScrollHandler.current?.() + + expect(scrollOffsetRef.current).toBe(0) + expect(anchorRef.current).toBe(savedAnchor) + }) }) diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts index d94ef02199f..7dd9764e7f6 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts @@ -8,6 +8,10 @@ import { } from 'react' import type { Virtualizer } from '@tanstack/react-virtual' import { shouldCancelVirtualizedScrollOffsetRestore } from './virtualizedScrollOffsetRestore' +import { + findVirtualizedDomScrollAnchor, + getVirtualizedScrollAnchorForOffset +} from './virtualized-scroll-anchor-recording' export type VirtualizedScrollAnchor = { fallbackKeys?: readonly string[] @@ -27,6 +31,10 @@ type UseVirtualizedScrollAnchorOptions< getRowKey: (row: TRow) => string hasDirectScrollInput?: () => boolean itemElementSelector?: string + recordAnchorOnCleanup?: boolean + // Why: some callers record user scroll anchors outside this hook; passive + // programmatic scroll events during remount must not teach them a transient row. + recordAnchorOnScroll?: boolean rows: readonly TRow[] scrollElementRef: RefObject scrollOffsetRef: MutableRefObject @@ -53,6 +61,8 @@ export function useVirtualizedScrollAnchor< getRowKey, hasDirectScrollInput, itemElementSelector, + recordAnchorOnCleanup = true, + recordAnchorOnScroll = true, rows, scrollElementRef, scrollOffsetRef, @@ -68,64 +78,14 @@ export function useVirtualizedScrollAnchor< return indexByKey }, [getRowKey, rows]) - const findDomAnchor = useCallback( - (scrollElement: TScrollElement) => { - if (!itemElementSelector || !getItemElementKey) { - return null - } - const scrollRect = scrollElement.getBoundingClientRect() - type DomAnchorItem = { element: TItemElement; key: string; rect: DOMRect } - const visibleItems = Array.from( - scrollElement.querySelectorAll(itemElementSelector) - ) - .map((element) => { - const key = getItemElementKey(element) - if (!key || !rowIndexByKey.has(key) || !element.isConnected) { - return null - } - const rect = element.getBoundingClientRect() - if (rect.height <= 0 || rect.bottom <= scrollRect.top || rect.top >= scrollRect.bottom) { - return null - } - return { element, key, rect } - }) - .filter((item): item is DomAnchorItem => item != null) - .sort((a, b) => a.rect.top - b.rect.top) - - const [firstVisible] = visibleItems - if (!firstVisible) { - return null - } - return { - fallbackKeys: visibleItems.slice(1).map((item) => item.key), - key: firstVisible.key, - offset: Math.min( - firstVisible.rect.height, - Math.max(0, scrollRect.top - firstVisible.rect.top) - ) - } - }, - [getItemElementKey, itemElementSelector, rowIndexByKey] - ) - const recordVirtualScrollAnchor = useCallback( (scrollTop: number) => { - const virtualItems = virtualizer.getVirtualItems() - const firstVisible = virtualItems.find((item) => item.end > scrollTop) - const row = firstVisible ? rows[firstVisible.index] : undefined - if (!firstVisible || !row) { - anchorRef.current = null - return - } - anchorRef.current = { - fallbackKeys: virtualItems - .slice(virtualItems.indexOf(firstVisible) + 1) - .map((item) => rows[item.index]) - .filter((row): row is TRow => row != null) - .map(getRowKey), - key: getRowKey(row), - offset: Math.max(0, scrollTop - firstVisible.start) - } + anchorRef.current = getVirtualizedScrollAnchorForOffset({ + getRowKey, + rows, + scrollTop, + virtualItems: virtualizer.getVirtualItems() + }) }, [anchorRef, getRowKey, rows, virtualizer] ) @@ -133,17 +93,36 @@ export function useVirtualizedScrollAnchor< const recordScrollAnchor = useCallback( (scrollTop: number) => { const scrollElement = scrollElementRef.current - if (scrollElement) { - const domAnchor = findDomAnchor(scrollElement) + if (scrollElement && itemElementSelector && getItemElementKey) { + const domAnchor = findVirtualizedDomScrollAnchor({ + getItemElementKey, + itemElementSelector, + rowIndexByKey, + scrollElement + }) if (domAnchor) { anchorRef.current = domAnchor return } } - recordVirtualScrollAnchor(scrollTop) + anchorRef.current = getVirtualizedScrollAnchorForOffset({ + getRowKey, + rows, + scrollTop, + virtualItems: virtualizer.getVirtualItems() + }) }, - [anchorRef, findDomAnchor, recordVirtualScrollAnchor, scrollElementRef] + [ + anchorRef, + getItemElementKey, + getRowKey, + itemElementSelector, + rowIndexByKey, + rows, + scrollElementRef, + virtualizer + ] ) // Why: row changes must not re-register the scroll listener; cleanup records @@ -154,6 +133,10 @@ export function useVirtualizedScrollAnchor< recordVirtualScrollAnchorRef.current = recordVirtualScrollAnchor const hasDirectScrollInputRef = useRef(hasDirectScrollInput) hasDirectScrollInputRef.current = hasDirectScrollInput + const recordAnchorOnCleanupRef = useRef(recordAnchorOnCleanup) + recordAnchorOnCleanupRef.current = recordAnchorOnCleanup + const recordAnchorOnScrollRef = useRef(recordAnchorOnScroll) + recordAnchorOnScrollRef.current = recordAnchorOnScroll useLayoutEffect(() => { const el = scrollElementRef.current @@ -216,18 +199,29 @@ export function useVirtualizedScrollAnchor< // user's real position until the intended offset is reachable. if (el.scrollTop === targetOffset) { restoring = false - recordCurrentAnchor() + if (recordAnchorOnScrollRef.current) { + recordCurrentAnchor() + } else { + scrollOffsetRef.current = el.scrollTop + } return } if (el.scrollHeight - el.clientHeight >= targetOffset) { el.scrollTop = targetOffset if (el.scrollTop === targetOffset) { restoring = false - recordCurrentAnchor() + if (recordAnchorOnScrollRef.current) { + recordCurrentAnchor() + } else { + scrollOffsetRef.current = el.scrollTop + } } } return } + if (!recordAnchorOnScrollRef.current) { + return + } scrollOffsetRef.current = el.scrollTop recordVirtualScrollAnchorRef.current(el.scrollTop) scheduleRecordAnchor() @@ -237,8 +231,10 @@ export function useVirtualizedScrollAnchor< el.addEventListener(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, recordCurrentAnchor) return () => { cancelScheduledRecord() - scrollOffsetRef.current = el.scrollTop - recordScrollAnchorRef.current(el.scrollTop) + if (recordAnchorOnCleanupRef.current) { + scrollOffsetRef.current = el.scrollTop + recordScrollAnchorRef.current(el.scrollTop) + } el.removeEventListener(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, recordCurrentAnchor) el.removeEventListener('scroll', onScroll) } @@ -250,9 +246,10 @@ export function useVirtualizedScrollAnchor< if (!anchor || !el) { return } - if (virtualizer.isScrolling) { + if (virtualizer.isScrolling && hasDirectScrollInputRef.current?.() === true) { // Why: remeasurement during wheel scrolling can change totalSize. Restoring // the anchor in that window writes scrollTop and fights the user's wheel. + // Programmatic scrolls during remount still need anchor correction. return } if (shouldSkipRestore?.()) { @@ -287,7 +284,11 @@ export function useVirtualizedScrollAnchor< const desiredTop = scrollRect.top - offset const delta = rect.top - desiredTop if (Math.abs(delta) > 1) { + // Why: this scroll write is still part of restore; keep the target + // anchor until a later layout confirms the intended row is in place. el.scrollTop += delta + scrollOffsetRef.current = el.scrollTop + return true } scrollOffsetRef.current = el.scrollTop recordScrollAnchor(el.scrollTop) @@ -304,8 +305,9 @@ export function useVirtualizedScrollAnchor< if (Math.abs(el.scrollTop - nextScrollTop) > 1) { el.scrollTop = nextScrollTop } + // Why: measured fallback can run while TanStack's virtual window is + // transitional, so recording here can replace the target with a wrong row. scrollOffsetRef.current = el.scrollTop - recordScrollAnchor(el.scrollTop) return true } diff --git a/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts b/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts new file mode 100644 index 00000000000..bf77d2426ff --- /dev/null +++ b/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts @@ -0,0 +1,75 @@ +import type { VirtualizedScrollAnchor } from './useVirtualizedScrollAnchor' + +type VirtualScrollItem = { + end: number + index: number + start: number +} + +export function findVirtualizedDomScrollAnchor({ + getItemElementKey, + itemElementSelector, + rowIndexByKey, + scrollElement +}: { + getItemElementKey: (element: TItemElement) => string | null + itemElementSelector: string + rowIndexByKey: ReadonlyMap + scrollElement: Element +}): NonNullable | null { + const scrollRect = scrollElement.getBoundingClientRect() + type DomAnchorItem = { key: string; rect: DOMRect } + const visibleItems = Array.from(scrollElement.querySelectorAll(itemElementSelector)) + .map((element) => { + const key = getItemElementKey(element) + if (!key || !rowIndexByKey.has(key) || !element.isConnected) { + return null + } + const rect = element.getBoundingClientRect() + if (rect.height <= 0 || rect.bottom <= scrollRect.top || rect.top >= scrollRect.bottom) { + return null + } + return { key, rect } + }) + .filter((item): item is DomAnchorItem => item != null) + .sort((a, b) => a.rect.top - b.rect.top) + + const [firstVisible] = visibleItems + if (!firstVisible) { + return null + } + + return { + fallbackKeys: visibleItems.slice(1).map((item) => item.key), + key: firstVisible.key, + offset: Math.min(firstVisible.rect.height, Math.max(0, scrollRect.top - firstVisible.rect.top)) + } +} + +export function getVirtualizedScrollAnchorForOffset({ + getRowKey, + rows, + scrollTop, + virtualItems +}: { + getRowKey: (row: TRow) => string + rows: readonly TRow[] + scrollTop: number + virtualItems: readonly VirtualScrollItem[] +}): VirtualizedScrollAnchor { + const firstVisible = virtualItems.find((item) => item.end > scrollTop) + const row = firstVisible ? rows[firstVisible.index] : undefined + if (!firstVisible || !row) { + return null + } + + return { + fallbackKeys: virtualItems + .slice(virtualItems.indexOf(firstVisible) + 1) + .map((item) => rows[item.index]) + .filter((row): row is TRow => row != null) + .map(getRowKey), + key: getRowKey(row), + offset: Math.max(0, scrollTop - firstVisible.start) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ce3555d7062..67e41a8e10f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1049,6 +1049,7 @@ "NewWorkspaceComposerCard": { "reuseExistingBranch": "Reuse branch", "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", + "createMultiple": "Create more", "cbb47ee0dc": "Only available for local Git projects.", "d861de981b": "Sparse checkout", "090cfedeb4": "Write a note", @@ -1272,7 +1273,10 @@ "2b4fdb880c": "Unmark viewed", "56ec6eafb7": "Open the PR details to view current reviewers.", "7f964a365a": "Remove reviewer", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "Move", @@ -1601,7 +1605,26 @@ "ff90d0abc7": "Start workspace from {{value0}}", "fe28c9821f": "view", "8d1e17a3ef": "Open {{value0}} in GitHub", - "4ac8ff2275": "Open {{value0}} in Jira" + "4ac8ff2275": "Open {{value0}} in Jira", + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "Close", @@ -1702,7 +1725,10 @@ "settingsBadge": "Settings", "actionBadge": "Action", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "Tab no longer exists" + "workspaceTabMissing": "Tab no longer exists", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -1845,7 +1871,10 @@ "22df63c393": "Sub-issue data is unavailable for your token.", "067119985c": "GitHub search, e.g. assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}} isn't added to Orca. Add it to start work, or open in GitHub.", - "1aa7c952b9": "Project view" + "1aa7c952b9": "Project view", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2496,7 +2525,8 @@ "SortableTab": { "ab19f603eb": "Rename tab {{value0}}", "6df69d9388": "Close tab {{value0}}", - "fdb2691425": "Collapse pane" + "fdb2691425": "Collapse pane", + "95db5f2f7d": "Close tab" }, "SortableTabContextMenu": { "35e8892fd0": "Tab Color", @@ -2620,6 +2650,10 @@ "down": "Down", "up": "Up", "moveToPaneColumn": "Move Tab to Split" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Close tab", + "a768f428f1": "Close tab" } } }, @@ -3835,6 +3869,7 @@ "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { + "copyLink": "Copy link", "reviewHeader": "{{value0}} #{{value1}}" }, "WorktreeCardMeta": { @@ -3851,6 +3886,12 @@ "807b13b9ec": "Edit issue", "b22f058067": "View on GitHub", "e97d8f2876": "Issue #{{value0}}", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link", "3ea2702e62": "Linked {{value0}} #{{value1}}", "b105fd3057": "Linked Linear {{value0}}", "3f2649eeb8": "Linked issue #{{value0}}", @@ -3952,7 +3993,8 @@ "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", - "failedNestWorkspace": "Failed to nest workspace" + "failedNestWorkspace": "Failed to nest workspace", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancel", @@ -9136,7 +9178,10 @@ "hideEmptySessions": "Hide empty sessions", "workspaceScope": "Workspace", "worktreeScope": "Worktree", - "globalScope": "Global" + "globalScope": "Global", + "projectScope": "Project", + "currentProjectLower": "current project", + "project": "Project" }, "AiVaultSessionDetails": { "originalAsk": "Original ask", @@ -10391,7 +10436,8 @@ "4cc7b83ffe": "Filter files...", "21783df79f": "Collapse file tree", "481e63ca52": "Files", - "d5ac717d65": "uncommitted" + "d5ac717d65": "uncommitted", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "in Source Control", @@ -10418,7 +10464,7 @@ "f786fd54e1": "Inline", "ea08dae15b": "Collapse All", "19c45cfdc0": "Expand All", - "982d14bfa5": "Open Uncommitted Diff", + "982d14bfa5": "Open All Changes", "3d909843bb": "Open Branch Diff", "8368d256ec": "combined-branch", "8f68ad9ca9": "Show {{value0}} AI {{value1}}", @@ -11046,6 +11092,14 @@ "newQuickCommand": "new quick command" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c862f5300fe..7b3c80169cb 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1102,7 +1102,8 @@ "importHostSetup": "Import", "notePasteTooLarge": "Paste is too large for the note field.", "reuseExistingBranch": "Reuse branch", - "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo." @@ -1272,7 +1273,10 @@ "2b4fdb880c": "Desmarcar visto", "56ec6eafb7": "Abra los detalles de relaciones públicas para ver los revisores actuales.", "7f964a365a": "Eliminar revisor", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "Mover", @@ -1601,7 +1605,26 @@ "ff90d0abc7": "Iniciar espacio de trabajo desde {{value0}}", "fe28c9821f": "vista", "8d1e17a3ef": "Abra {{value0}} en GitHub", - "4ac8ff2275": "Abrir {{value0}} en Jira" + "4ac8ff2275": "Abrir {{value0}} en Jira", + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "Cerca", @@ -1702,7 +1725,10 @@ "settingsBadge": "Ajustes", "actionBadge": "Acción", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "La pestaña ya no existe" + "workspaceTabMissing": "La pestaña ya no existe", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -1845,7 +1871,10 @@ "22df63c393": "Los datos de la subemisión no están disponibles para su token.", "067119985c": "Búsqueda de GitHub, p. cesionario:@yo es:abierto", "1850fceac8": "{{value0}}/{{value1}} no se agrega a Orca. Agréguelo para comenzar a trabajar o ábralo en GitHub.", - "1aa7c952b9": "Project view" + "1aa7c952b9": "Project view", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2496,7 +2525,8 @@ "SortableTab": { "ab19f603eb": "Cambiar nombre de pestaña {{value0}}", "6df69d9388": "Cerrar pestaña {{value0}}", - "fdb2691425": "Contraer panel" + "fdb2691425": "Contraer panel", + "95db5f2f7d": "Cerrar pestaña" }, "SortableTabContextMenu": { "35e8892fd0": "Color de pestaña", @@ -2620,6 +2650,10 @@ "down": "Abajo", "up": "Arriba", "moveToPaneColumn": "Mover pestaña a división" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "Cerrar pestaña", + "a768f428f1": "Cerrar pestaña" } } }, @@ -3737,7 +3771,7 @@ "2d74665a56": "Puertos", "65a9820bd1": "Estados del agente", "219ebf1961": "Nombre de rama", - "folderPathIdentity": "Rama / ruta de carpeta" + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "Conectando...", @@ -3814,14 +3848,15 @@ "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", "automationCreated": "Created by automation", - "branchIdentity": "Rama", - "branchFolderPathIdentity": "Rama o ruta de carpeta" + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { - "reviewHeader": "{{value0}} #{{value1}}" + "reviewHeader": "{{value0}} #{{value1}}", + "copyLink": "Copy link" }, "WorktreeCardMeta": { "3e65e11cc6": "Metadatos del espacio de trabajo", @@ -3848,7 +3883,13 @@ "checkingAutomationAvailability": "Comprobando disponibilidad de la automatización...", "automationMissing": "La automatización ya no está disponible.", "automationRunMissing": "El historial de ejecuciones ya no está disponible.", - "automationAvailabilityUnavailable": "No se pudo comprobar la disponibilidad de la automatización." + "automationAvailabilityUnavailable": "No se pudo comprobar la disponibilidad de la automatización.", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "Estado: Abierto", @@ -3938,7 +3979,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "No se pudo anidar el espacio de trabajo" + "failedNestWorkspace": "No se pudo anidar el espacio de trabajo", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancelar", @@ -4269,7 +4311,7 @@ "empty": "No hay árboles de trabajo elegibles que coincidan." }, "WorktreeCardStatusSlot": { - "branchIdentity": "Rama" + "branchIdentity": "Branch" } }, "shared": { @@ -6223,19 +6265,19 @@ "29154326bb": "en", "ab20575a8a": "apagado", "ab3a1f9068": "wsl.exe", - "ask_before_closing_running_terminals_title": "Preguntar antes de cerrar terminales en ejecución", - "ask_before_closing_running_terminals_description": "Muestra una confirmación antes de cerrar un terminal que tiene un comando o agente en ejecución.", + "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", "scrollSpeed": { "title": "Velocidad de desplazamiento", "description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa.", - "helper": "Ajusta cómo se siente la entrada de la rueda en el historial de desplazamiento y en apps de terminal compatibles con el ratón.", + "helper": "Ajusta cómo se siente la entrada de la rueda en el historial y en apps de terminal compatibles con mouse.", "reset": "Restablecer", "normal": "Normal", - "normalDescription": "Multiplicador de la rueda en el historial.", + "normalDescription": "Multiplicador de rueda del historial.", "fast": "Rápido", - "fastDescription": "Multiplicador extra al desplazarse con una tecla modificadora.", + "fastDescription": "Multiplicador adicional al desplazarse con una tecla modificadora.", "tui": "TUI", - "tuiDescription": "Informes discretos de rueda para apps de terminal de pantalla completa." + "tuiDescription": "Eventos discretos de rueda para apps de terminal de pantalla completa." } }, "TerminalSettingsPreview": { @@ -7871,8 +7913,8 @@ "keyword_yaml": "yaml", "keyword_custom": "custom" }, - "ask_before_closing_running_terminals_title": "Preguntar antes de cerrar terminales en ejecución", - "ask_before_closing_running_terminals_description": "Muestra una confirmación antes de cerrar un terminal que tiene un comando o agente en ejecución.", + "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", "scrollSpeed": { "title": "Velocidad de desplazamiento", "description": "Ajusta el desplazamiento normal del terminal, el desplazamiento rápido con modificador y la velocidad de rueda en TUI de pantalla completa." @@ -9136,7 +9178,10 @@ "hideEmptySessions": "Hide empty sessions", "workspaceScope": "Workspace", "worktreeScope": "Worktree", - "globalScope": "Global" + "globalScope": "Global", + "projectScope": "Proyecto", + "currentProjectLower": "proyecto actual", + "project": "Proyecto" }, "AiVaultSessionDetails": { "updated": "Updated", @@ -10391,7 +10436,8 @@ "4cc7b83ffe": "Filtrar archivos...", "21783df79f": "Contraer árbol de archivos", "481e63ca52": "Archivos", - "d5ac717d65": "no comprometido" + "d5ac717d65": "no comprometido", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "en control de fuente", @@ -11046,6 +11092,14 @@ "newQuickCommand": "nuevo comando rápido" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8e3e74dba75..7c5a9bfcdcb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1102,7 +1102,8 @@ "importHostSetup": "インポート", "notePasteTooLarge": "Paste is too large for the note field.", "reuseExistingBranch": "Reuse branch", - "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "ワークスペースを作成する前に、プロジェクト、ワークスペース名、および agent を選択します。" @@ -1272,7 +1273,10 @@ "2b4fdb880c": "閲覧済みのマークを外す", "56ec6eafb7": "PR の詳細を開いて、現在のレビュアーを表示します。", "7f964a365a": "レビュアーを削除", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "移動", @@ -1601,7 +1605,26 @@ "ff90d0abc7": "{{value0}} からワークスペースを開始", "fe28c9821f": "view", "8d1e17a3ef": "{{value0}} を GitHub で開く", - "4ac8ff2275": "{{value0}} を Jira で開く" + "4ac8ff2275": "{{value0}} を Jira で開く", + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "閉じる", @@ -1702,7 +1725,10 @@ "settingsBadge": "設定", "actionBadge": "操作", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "タブはもう存在しません" + "workspaceTabMissing": "タブはもう存在しません", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -1845,7 +1871,10 @@ "22df63c393": "トークンではサブ Issue データを利用できません。", "067119985c": "GitHub 検索、例:担当者:@me は:開いています", "1850fceac8": "{{value0}}/{{value1}} は Orca に追加されません。これを追加して作業を開始するか、GitHub で開きます。", - "1aa7c952b9": "Project view" + "1aa7c952b9": "Project view", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2312,7 +2341,7 @@ "TerminalContextMenu": { "b4cdd9314e": "クリアスクリーン", "8c17d6786d": "ペインを閉じる", - "copyTerminalId": "Copy Terminal ID", + "copyTerminalId": "ターミナル ID をコピー", "2cf85a6a55": "ペインIDのコピー", "39809d152f": "タイトルを設定…", "06c2b0f043": "ペインのサイズを均等化する", @@ -2404,9 +2433,9 @@ "a29b9faa01": "ペイン ID がコピーされました", "terminal": { "id": { - "copied": "Terminal ID をコピーしました", + "copied": "ターミナル ID をコピーしました", "copy": { - "failed": "Terminal ID をコピーできません" + "failed": "ターミナル ID をコピーできません" } } } @@ -2496,7 +2525,8 @@ "SortableTab": { "ab19f603eb": "タブ {{value0}} の名前を変更します", "6df69d9388": "タブ {{value0}} を閉じる", - "fdb2691425": "ペインを折りたたむ" + "fdb2691425": "ペインを折りたたむ", + "95db5f2f7d": "タブを閉じる" }, "SortableTabContextMenu": { "35e8892fd0": "タブの色", @@ -2620,6 +2650,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "タブを分割へ移動" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "タブを閉じる", + "a768f428f1": "タブを閉じる" } } }, @@ -3718,7 +3752,7 @@ "65a9820bd1": "Agent ステータス", "219ebf1961": "ブランチ名", "automation": "Automation", - "folderPathIdentity": "ブランチ / フォルダパス" + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "接続中...", @@ -3795,14 +3829,15 @@ "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", "automationCreated": "Created by automation", - "branchIdentity": "ブランチ", - "branchFolderPathIdentity": "ブランチまたはフォルダパス" + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { - "reviewHeader": "{{value0}} #{{value1}}" + "reviewHeader": "{{value0}} #{{value1}}", + "copyLink": "Copy link" }, "WorktreeCardMeta": { "3e65e11cc6": "ワークスペースのメタデータ", @@ -3829,7 +3864,13 @@ "checkingAutomationAvailability": "自動化の利用可否を確認中...", "automationMissing": "自動化は利用できなくなりました。", "automationRunMissing": "実行履歴は利用できなくなりました。", - "automationAvailabilityUnavailable": "自動化の利用可否を確認できませんでした。" + "automationAvailabilityUnavailable": "自動化の利用可否を確認できませんでした。", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状態: オープン", @@ -3919,7 +3960,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "ワークスペースをネストできませんでした" + "failedNestWorkspace": "ワークスペースをネストできませんでした", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "キャンセル", @@ -4269,7 +4311,7 @@ "empty": "一致する対象ワークツリーがありません。" }, "WorktreeCardStatusSlot": { - "branchIdentity": "ブランチ" + "branchIdentity": "Branch" } }, "shared": { @@ -6245,12 +6287,12 @@ "29154326bb": "の上", "ab20575a8a": "オフ", "ab3a1f9068": "wsl.exe", - "ask_before_closing_running_terminals_title": "実行中のターミナルを閉じる前に確認", - "ask_before_closing_running_terminals_description": "実行中のコマンドまたはエージェントがあるターミナルを閉じる前に確認を表示します。", + "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", "scrollSpeed": { "title": "スクロール速度", - "description": "通常のターミナルスクロールバック、修飾キー付き高速スクロール、全画面 TUI のホイール速度を調整します。", - "helper": "スクロールバックやマウス対応ターミナルアプリでのホイール操作の感触を調整します。", + "description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。", + "helper": "ホイール入力の感触をスクロールバックとマウス対応のターミナルアプリで調整します。", "reset": "リセット", "normal": "通常", "normalDescription": "スクロールバックのホイール倍率。", @@ -7893,11 +7935,11 @@ "33031c1465": "文字サイズ", "0fe0073f0c": "新規ペインとライブ アップデートのデフォルトの terminal フォント サイズ。", "5930244899": "フォントサイズ", - "ask_before_closing_running_terminals_title": "実行中のターミナルを閉じる前に確認", - "ask_before_closing_running_terminals_description": "実行中のコマンドまたはエージェントがあるターミナルを閉じる前に確認を表示します。", + "ask_before_closing_running_terminals_title": "Ask Before Closing Running Terminals", + "ask_before_closing_running_terminals_description": "Show a confirmation before closing a terminal that has a running command or agent.", "scrollSpeed": { "title": "スクロール速度", - "description": "通常のターミナルスクロールバック、修飾キー付き高速スクロール、全画面 TUI のホイール速度を調整します。" + "description": "通常のターミナルスクロールバック、修飾キーでの高速スクロール、全画面 TUI のホイール速度を調整します。" } }, "windows": { @@ -9136,7 +9178,10 @@ "hideEmptySessions": "Hide empty sessions", "workspaceScope": "ワークスペース", "worktreeScope": "Worktree", - "globalScope": "Global" + "globalScope": "Global", + "projectScope": "プロジェクト", + "currentProjectLower": "現在のプロジェクト", + "project": "プロジェクト" }, "AiVaultSessionDetails": { "updated": "Updated", @@ -10391,7 +10436,8 @@ "4cc7b83ffe": "ファイルをフィルタリング...", "21783df79f": "ファイルツリーを折りたたむ", "481e63ca52": "ファイル", - "d5ac717d65": "commit されていない" + "d5ac717d65": "commit されていない", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "ソース管理内", @@ -11046,6 +11092,14 @@ "newQuickCommand": "新規クイックコマンド" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5e9625ab84b..6075a27c437 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1102,7 +1102,8 @@ "importHostSetup": "가져오기", "notePasteTooLarge": "메모 필드에 붙여넣을 내용이 너무 큽니다.", "reuseExistingBranch": "Reuse branch", - "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "워크스페이스를 만들기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요." @@ -1272,7 +1273,10 @@ "2b4fdb880c": "본 표시 해제", "56ec6eafb7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.", "7f964a365a": "리뷰어 삭제", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "이동", @@ -1601,7 +1605,26 @@ "ff90d0abc7": "{{value0}}에서 워크스페이스 시작", "fe28c9821f": "보기", "8d1e17a3ef": "GitHub에서 {{value0}} 열기", - "4ac8ff2275": "Jira에서 {{value0}} 열기" + "4ac8ff2275": "Jira에서 {{value0}} 열기", + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "닫기", @@ -1702,7 +1725,10 @@ "settingsBadge": "설정", "actionBadge": "작업", "paletteHostBadge": "호스트: {{value0}}", - "workspaceTabMissing": "탭이 더 이상 존재하지 않습니다" + "workspaceTabMissing": "탭이 더 이상 존재하지 않습니다", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -1845,7 +1871,10 @@ "22df63c393": "토큰에 대한 하위 이슈 데이터를 사용할 수 없습니다.", "067119985c": "GitHub 검색, 예: assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}}이(가) Orca에 추가되어 있지 않습니다. 작업을 시작하려면 추가하거나 GitHub에서 여세요.", - "1aa7c952b9": "프로젝트 보기" + "1aa7c952b9": "프로젝트 보기", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2312,7 +2341,7 @@ "TerminalContextMenu": { "b4cdd9314e": "화면 지우기", "8c17d6786d": "창 닫기", - "copyTerminalId": "Copy Terminal ID", + "copyTerminalId": "터미널 ID 복사", "2cf85a6a55": "창 ID 복사", "39809d152f": "제목 설정…", "06c2b0f043": "창 크기 균등화", @@ -2404,7 +2433,7 @@ "a29b9faa01": "창 ID가 복사되었습니다.", "terminal": { "id": { - "copied": "터미널 ID가 복사되었습니다", + "copied": "터미널 ID를 복사했습니다", "copy": { "failed": "터미널 ID를 복사할 수 없습니다" } @@ -2496,7 +2525,8 @@ "SortableTab": { "ab19f603eb": "탭 이름 바꾸기 {{value0}}", "6df69d9388": "탭 닫기 {{value0}}", - "fdb2691425": "창 축소" + "fdb2691425": "창 축소", + "95db5f2f7d": "탭 닫기" }, "SortableTabContextMenu": { "35e8892fd0": "탭 색상", @@ -2620,6 +2650,10 @@ "down": "아래", "up": "위", "moveToPaneColumn": "탭을 분할로 이동" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "탭 닫기", + "a768f428f1": "탭 닫기" } } }, @@ -3718,7 +3752,7 @@ "65a9820bd1": "Agent 상태", "219ebf1961": "브랜치 이름", "automation": "자동화", - "folderPathIdentity": "브랜치 / 폴더 경로" + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "연결 중...", @@ -3795,14 +3829,15 @@ "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", "automationCreated": "Created by automation", - "branchIdentity": "브랜치", - "branchFolderPathIdentity": "브랜치 또는 폴더 경로" + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { - "reviewHeader": "{{value0}} #{{value1}}" + "reviewHeader": "{{value0}} #{{value1}}", + "copyLink": "Copy link" }, "WorktreeCardMeta": { "3e65e11cc6": "워크스페이스 메타데이터", @@ -3829,7 +3864,13 @@ "checkingAutomationAvailability": "자동화 사용 가능 여부 확인 중...", "automationMissing": "자동화를 더 이상 사용할 수 없습니다.", "automationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.", - "automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다." + "automationAvailabilityUnavailable": "자동화 사용 가능 여부를 확인할 수 없습니다.", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "상태: 열림", @@ -3919,7 +3960,8 @@ "7a8b9c0d1e": "업데이트 필요", "hostAuthNeeded": "인증 필요", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다" + "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "취소", @@ -4269,7 +4311,7 @@ "empty": "일치하는 적격 워크트리가 없습니다." }, "WorktreeCardStatusSlot": { - "branchIdentity": "브랜치" + "branchIdentity": "Branch" } }, "shared": { @@ -6213,12 +6255,12 @@ "scrollSpeed": { "title": "스크롤 속도", "description": "일반 터미널 스크롤백, 수정 키 빠른 스크롤, 전체 화면 TUI 휠 속도를 조정합니다.", - "helper": "스크롤백과 마우스 지원 터미널 앱에서 휠 입력의 느낌을 조정합니다.", + "helper": "휠 입력이 스크롤백과 마우스 인식 터미널 앱에서 느껴지는 방식을 조정합니다.", "reset": "재설정", "normal": "일반", "normalDescription": "스크롤백 휠 배율.", "fast": "빠름", - "fastDescription": "수정 키로 스크롤할 때의 추가 배율.", + "fastDescription": "수정 키로 스크롤할 때 적용되는 추가 배율.", "tui": "TUI", "tuiDescription": "전체 화면 터미널 앱을 위한 개별 휠 보고." } @@ -9136,7 +9178,10 @@ "hideEmptySessions": "빈 세션 숨기기", "workspaceScope": "워크스페이스", "worktreeScope": "워크트리", - "globalScope": "전역" + "globalScope": "전역", + "projectScope": "프로젝트", + "currentProjectLower": "현재 프로젝트", + "project": "프로젝트" }, "AiVaultSessionDetails": { "updated": "업데이트됨", @@ -10391,7 +10436,8 @@ "4cc7b83ffe": "파일 필터링...", "21783df79f": "파일 트리 축소", "481e63ca52": "파일", - "d5ac717d65": "commit 안 됨" + "d5ac717d65": "commit 안 됨", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "소스 제어에서", @@ -11046,6 +11092,14 @@ "newQuickCommand": "새로운 빠른 명령" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6d57d90932e..dcf53886716 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1102,7 +1102,8 @@ "importHostSetup": "导入", "notePasteTooLarge": "笔记字段的粘贴内容过大。", "reuseExistingBranch": "复用分支", - "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。" + "reuseExistingBranchHint": "检出已有分支,而不是基于它创建新分支。", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "创建工作区之前选择项目、工作区名称和 Agent。" @@ -1272,7 +1273,10 @@ "2b4fdb880c": "取消标记已查看", "56ec6eafb7": "打开 PR 详情以查看当前评审人。", "7f964a365a": "移除评审人", - "commentTooLarge": "评论过长,无法安全提交。" + "commentTooLarge": "评论过长,无法安全提交。", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "手机", @@ -1601,7 +1605,26 @@ "ff90d0abc7": "从 {{value0}} 开始工作区", "fe28c9821f": "视图", "8d1e17a3ef": "在 GitHub 中打开 {{value0}}", - "4ac8ff2275": "在 Jira 中打开 {{value0}}" + "4ac8ff2275": "在 Jira 中打开 {{value0}}", + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "关闭", @@ -1702,7 +1725,10 @@ "settingsBadge": "设置", "actionBadge": "操作", "paletteHostBadge": "主机:{{value0}}", - "workspaceTabMissing": "标签页不再存在" + "workspaceTabMissing": "标签页不再存在", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -1845,7 +1871,10 @@ "22df63c393": "您的 Token 无法获取子议题数据。", "067119985c": "GitHub 搜索,例如 assignee:@me is:open", "1850fceac8": "{{value0}}/{{value1}} 未添加到 Orca。添加它以开始工作,或在 GitHub 中打开。", - "1aa7c952b9": "项目视图" + "1aa7c952b9": "项目视图", + "f352abf7c3": "Repository list is updating.", + "1ce21b8cff": "This item is outside the selected repositories.", + "030de75bc5": "This item matches multiple selected repositories." }, "slug": { "dialog": { @@ -2312,7 +2341,7 @@ "TerminalContextMenu": { "b4cdd9314e": "清晰的屏幕", "8c17d6786d": "关闭窗格", - "copyTerminalId": "复制 Terminal ID", + "copyTerminalId": "复制终端 ID", "2cf85a6a55": "复制窗格 ID", "39809d152f": "设置标题...", "06c2b0f043": "均衡窗格大小", @@ -2496,7 +2525,8 @@ "SortableTab": { "ab19f603eb": "重命名选项卡 {{value0}}", "6df69d9388": "关闭选项卡 {{value0}}", - "fdb2691425": "折叠窗格" + "fdb2691425": "折叠窗格", + "95db5f2f7d": "关闭选项卡" }, "SortableTabContextMenu": { "35e8892fd0": "标签颜色", @@ -2620,6 +2650,10 @@ "down": "下", "up": "上", "moveToPaneColumn": "将标签页移至拆分" + }, + "EditorFileTabCloseButton": { + "4655cf570e": "关闭选项卡", + "a768f428f1": "关闭选项卡" } } }, @@ -3718,7 +3752,7 @@ "65a9820bd1": "Agent 状态", "219ebf1961": "分支名称", "automation": "Automation", - "folderPathIdentity": "分支 / 文件夹路径" + "folderPathIdentity": "Branch / folder path" }, "SshDisconnectedDialog": { "ca4a7892af": "正在连接...", @@ -3795,14 +3829,15 @@ "runtimeHostDisconnected": "Server disconnected", "runtimeHostProject": "Project on Orca server", "automationCreated": "Created by automation", - "branchIdentity": "分支", - "branchFolderPathIdentity": "分支或文件夹路径" + "branchIdentity": "Branch", + "branchFolderPathIdentity": "Branch or folder path" }, "WorktreeCardAgents": { "1b0a156717": "Agents" }, "WorktreeCardReviewDetailSection": { - "reviewHeader": "{{value0}} #{{value1}}" + "reviewHeader": "{{value0}} #{{value1}}", + "copyLink": "Copy link" }, "WorktreeCardMeta": { "3e65e11cc6": "工作区元数据", @@ -3829,7 +3864,13 @@ "checkingAutomationAvailability": "正在检查自动化可用性...", "automationMissing": "自动化已不可用。", "automationRunMissing": "运行历史已不可用。", - "automationAvailabilityUnavailable": "无法检查自动化可用性。" + "automationAvailabilityUnavailable": "无法检查自动化可用性。", + "moreIssueActions": "More issue actions", + "copyLink": "Copy link", + "copyLinkSuccess": "{{value0}} copied", + "copyLinkFailure": "Failed to copy link", + "issueLinkLabel": "Issue link", + "reviewLinkLabel": "{{value0}} link" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状态:开放", @@ -3919,7 +3960,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "无法嵌套工作区" + "failedNestWorkspace": "无法嵌套工作区", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "取消", @@ -4269,7 +4311,7 @@ "empty": "没有匹配的可用工作树。" }, "WorktreeCardStatusSlot": { - "branchIdentity": "分支" + "branchIdentity": "Branch" } }, "shared": { @@ -6212,13 +6254,13 @@ "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。", "scrollSpeed": { "title": "滚动速度", - "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 鼠标滚轮速度。", - "helper": "调整在回滚区域和支持鼠标的终端应用中的滚轮输入手感。", + "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。", + "helper": "调整滚轮输入在回滚和支持鼠标的终端应用中的手感。", "reset": "重置", "normal": "普通", - "normalDescription": "回滚滚轮倍率。", + "normalDescription": "回滚滚轮倍数。", "fast": "快速", - "fastDescription": "按住修饰键滚动时的额外倍率。", + "fastDescription": "按住修饰键滚动时的额外倍数。", "tui": "TUI", "tuiDescription": "面向全屏终端应用的离散滚轮报告。" } @@ -7860,7 +7902,7 @@ "ask_before_closing_running_terminals_description": "在关闭有运行中命令或 agent 的 terminal 前显示确认。", "scrollSpeed": { "title": "滚动速度", - "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 鼠标滚轮速度。" + "description": "调整普通终端回滚、修饰键快速滚动以及全屏 TUI 滚轮速度。" } }, "windows": { @@ -9136,7 +9178,10 @@ "hideEmptySessions": "隐藏空会话", "workspaceScope": "工作区", "worktreeScope": "工作树", - "globalScope": "全局" + "globalScope": "全局", + "projectScope": "项目", + "currentProjectLower": "当前项目", + "project": "项目" }, "AiVaultSessionDetails": { "updated": "更新时间", @@ -10391,7 +10436,8 @@ "4cc7b83ffe": "搜索文件...", "21783df79f": "折叠文件树", "481e63ca52": "文件", - "d5ac717d65": "未提交" + "d5ac717d65": "未提交", + "39b6b9e4e4": "Committed on Branch" }, "CombinedDiffViewer": { "35cc27aeb2": "在源代码控制中", @@ -11046,6 +11092,14 @@ "newQuickCommand": "新的快速命令" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts new file mode 100644 index 00000000000..d5a4c6ff9b9 --- /dev/null +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -0,0 +1,49 @@ +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' +import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' +import type { GlobalSettings } from '../../../shared/types' + +type RuntimeOwnerSettings = Pick | null | undefined + +export async function sendFollowupPromptWhenAgentReady(args: { + ptyId: string + expectedProcess: string + prompt: string + settings: RuntimeOwnerSettings +}): Promise { + const { ptyId, expectedProcess, prompt, settings } = args + if (!(await waitForAgentForeground(ptyId, expectedProcess, settings))) { + return false + } + try { + return await sendRuntimePtyInputVerified(settings, ptyId, `${prompt}\r`) + } catch { + return false + } +} + +// Why: delayed follow-ups must not type into an arbitrary shell. Require a +// positive expected-process match before writing user/task text to the PTY. +async function waitForAgentForeground( + ptyId: string, + expectedProcess: string, + settings: RuntimeOwnerSettings +): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => globalThis.setTimeout(resolve, 150)) + } + try { + const process = await inspectRuntimeTerminalProcess(settings, ptyId) + const foreground = process.foregroundProcess?.toLowerCase() ?? '' + if (isExpectedAgentProcess(foreground, expectedProcess)) { + return true + } + } catch { + // Ignore transient PTY inspection failures and keep polling. + } + } + return false +} diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index ae687021224..2c6ae9ab6f0 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -6,6 +6,7 @@ import { chunkAgentDraftPasteContent, getSettingsForAgentTabRuntimeOwner, iterateAgentDraftPasteContentChunks, + pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady, sendAgentDraftPasteContent, sendBracketedPasteToRunningAgent @@ -275,6 +276,30 @@ describe('pasteDraftWhenAgentReady', () => { ) }) + it('honors the fallback inspection deadline for pty-bound draft paste', async () => { + const onTimeout = vi.fn() + testState.inspectRuntimeTerminalProcess.mockReturnValue(new Promise(() => {})) + + const promise = pasteDraftToAgentPtyWhenReady({ + tabId: 'tab-1', + ptyId: 'pty-1', + content: ISSUE_URL, + agent: 'codex', + forcePaste: true, + timeoutMs: 1, + onTimeout + }) + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(1) + await flushMicrotasks(5) + await vi.advanceTimersByTimeAsync(1000) + + await expect(promise).resolves.toBe(false) + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) + 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' }] } diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 4847004d309..6fc186a1326 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,7 +1,10 @@ import type { TuiAgent } from '../../../shared/types' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { useAppStore } from '@/store' -import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, @@ -12,6 +15,7 @@ import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' import type { GlobalSettings } from '../../../shared/types' import { sendAgentDraftPasteContent } from './agent-draft-paste-content' import { waitForAgentDraftInputReady } from './agent-draft-readiness' +import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' export { AGENT_DRAFT_PASTE_CHUNK_MAX_BYTES, AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES, @@ -123,6 +127,45 @@ export async function pasteDraftWhenAgentReady(args: { }) } +export async function pasteDraftToAgentPtyWhenReady(args: { + tabId: string + ptyId: string + content: string + agent?: TuiAgent + submit?: boolean + forcePaste?: boolean + timeoutMs?: number + onTimeout?: () => void +}): Promise { + const { tabId, ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout } = args + const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null + + if (!forcePaste && (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar)) { + return false + } + + const budget = timeoutMs ?? READINESS_TIMEOUT_MS + const settings = getSettingsForAgentTabRuntimeOwner(tabId) + const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings) + if (!ready) { + const fallbackReady = agentConfig + ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) + : false + if (!fallbackReady) { + onTimeout?.() + return false + } + } + + return await sendBracketedPasteToAgent({ + settings, + ptyId, + content, + submit: submit === true + }) +} + export async function submitPromptToAgentTab(args: { tabId: string content: string @@ -191,3 +234,53 @@ async function waitForPtyId(tabId: string, timeoutMs: number): Promise | null | undefined +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const process = await withDeadline( + inspectRuntimeTerminalProcess(settings, ptyId), + Math.max(0, deadline - Date.now()) + ) + if (!process) { + return false + } + const foreground = process.foregroundProcess?.toLowerCase() ?? '' + if (isExpectedAgentProcess(foreground, expectedProcess)) { + return true + } + } catch { + // Ignore transient PTY inspection failures and keep polling. + } + const delayMs = Math.min(120, Math.max(0, deadline - Date.now())) + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)) + } + } + return false +} + +function withDeadline(promise: Promise, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + return Promise.resolve(null) + } + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => resolve(null), timeoutMs) + promise.then( + (value) => { + window.clearTimeout(timer) + resolve(value) + }, + (error) => { + window.clearTimeout(timer) + reject(error) + } + ) + }) +} diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts new file mode 100644 index 00000000000..deba11170a6 --- /dev/null +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -0,0 +1,212 @@ +import { useAppStore } from '@/store' +import type { AgentStartupPlan } from '@/lib/tui-agent-startup' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +type AppStoreSnapshot = ReturnType + +type PendingAgentStartupDelivery = { + worktreeId: string + tabId: string + launchToken: string + startup: AgentStartupPlan + deliver: (tabId: string, ptyId: string, startup: AgentStartupPlan) => Promise +} + +const pendingAgentStartupDeliveries = new Map() +const consumedAgentStartupDeliveries = new Set() +const staleStartupRecheckTimers = new Map>() +let unsubscribePendingAgentStartupDeliveries: (() => void) | null = null + +export function resolveAgentStartupTabId( + state: AppStoreSnapshot, + worktreeId: string, + primaryTabId: string | null | undefined +): string | null { + // Why: the caller may know the exact tab that received the queued startup + // command. Prefer it over focus-derived state, which can change mid-create. + return ( + primaryTabId ?? + state.activeTabIdByWorktree[worktreeId] ?? + state.tabsByWorktree[worktreeId]?.[0]?.id ?? + null + ) +} + +export function getAgentStartupTabPtyId( + state: AppStoreSnapshot, + tabId: string, + launchToken: string +): string | null { + const livePtyIds = new Set(state.ptyIdsByTabId[tabId] ?? []) + if (livePtyIds.size === 0) { + return null + } + for (const [paneKey, entry] of Object.entries(state.agentLaunchConfigByPaneKey ?? {})) { + const identity = entry.identity + if (identity.tabId !== tabId || identity.launchToken !== launchToken) { + continue + } + const leafId = identity.leafId ?? parsePaneKey(paneKey)?.leafId + if (!leafId) { + continue + } + const ptyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + if (ptyId && livePtyIds.has(ptyId)) { + return ptyId + } + } + return null +} + +function worktreeStillOwnsStartupTab( + state: AppStoreSnapshot, + worktreeId: string, + tabId: string +): boolean { + return (state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === tabId) +} + +function getPendingStartupLaunchToken(state: AppStoreSnapshot, tabId: string): string | undefined { + return state.pendingStartupByTabId?.[tabId]?.launchToken +} + +function hasRegisteredStartupLaunch( + state: AppStoreSnapshot, + tabId: string, + launchToken: string +): boolean { + return Object.values(state.agentLaunchConfigByPaneKey ?? {}).some( + (entry) => entry.identity.tabId === tabId && entry.identity.launchToken === launchToken + ) +} + +function ensurePendingAgentStartupSubscription(): void { + if (unsubscribePendingAgentStartupDeliveries) { + return + } + unsubscribePendingAgentStartupDeliveries = useAppStore.subscribe(() => { + flushPendingAgentStartupDeliveries() + }) +} + +function stopPendingAgentStartupSubscriptionIfIdle(): void { + if (pendingAgentStartupDeliveries.size > 0 || !unsubscribePendingAgentStartupDeliveries) { + return + } + unsubscribePendingAgentStartupDeliveries() + unsubscribePendingAgentStartupDeliveries = null +} + +export function queuePendingAgentStartupDelivery(delivery: PendingAgentStartupDelivery): void { + const key = deliveryKey(delivery) + if (consumedAgentStartupDeliveries.has(key)) { + return + } + pendingAgentStartupDeliveries.set(key, delivery) + ensurePendingAgentStartupSubscription() + flushPendingAgentStartupDeliveries() +} + +export function resetAgentStartupDelayedDeliveryForTests(): void { + pendingAgentStartupDeliveries.clear() + consumedAgentStartupDeliveries.clear() + for (const timer of staleStartupRecheckTimers.values()) { + globalThis.clearTimeout(timer) + } + staleStartupRecheckTimers.clear() + unsubscribePendingAgentStartupDeliveries?.() + unsubscribePendingAgentStartupDeliveries = null +} + +export function beginAgentStartupDeliveryAttempt(args: { + worktreeId: string + tabId: string + launchToken: string +}): boolean { + const key = deliveryKey(args) + if (consumedAgentStartupDeliveries.has(key)) { + return false + } + consumedAgentStartupDeliveries.add(key) + pendingAgentStartupDeliveries.delete(key) + clearStaleStartupRecheck(key) + return true +} + +function deliveryKey( + delivery: Pick +): string { + return `${delivery.worktreeId}\0${delivery.tabId}\0${delivery.launchToken}` +} + +function flushPendingAgentStartupDeliveries(): void { + const state = useAppStore.getState() + for (const [key, delivery] of pendingAgentStartupDeliveries) { + const { tabId, launchToken } = delivery + if (!worktreeStillOwnsStartupTab(state, delivery.worktreeId, tabId)) { + pendingAgentStartupDeliveries.delete(key) + continue + } + const queuedLaunchToken = getPendingStartupLaunchToken(state, tabId) + const launchRegistered = hasRegisteredStartupLaunch(state, tabId, launchToken) + if (queuedLaunchToken !== launchToken && !launchRegistered && queuedLaunchToken !== undefined) { + pendingAgentStartupDeliveries.delete(key) + clearStaleStartupRecheck(key) + continue + } + if (queuedLaunchToken === undefined && !launchRegistered) { + scheduleStaleStartupRecheck(key) + continue + } + const ptyId = getAgentStartupTabPtyId(state, tabId, launchToken) + if (!ptyId) { + continue + } + // Why: once the launch-bound PTY exists, the bounded readiness/paste path + // owns success or failure. Consume before awaiting so store churn cannot + // duplicate a linked-work-item draft. + if (beginAgentStartupDeliveryAttempt(delivery)) { + void delivery.deliver(tabId, ptyId, delivery.startup).catch((error) => { + console.warn('Queued agent startup delivery failed', error) + }) + } + } + stopPendingAgentStartupSubscriptionIfIdle() +} + +function scheduleStaleStartupRecheck(key: string): void { + if (staleStartupRecheckTimers.has(key)) { + return + } + staleStartupRecheckTimers.set( + key, + globalThis.setTimeout(() => { + staleStartupRecheckTimers.delete(key) + const delivery = pendingAgentStartupDeliveries.get(key) + if (!delivery) { + stopPendingAgentStartupSubscriptionIfIdle() + return + } + const state = useAppStore.getState() + const queuedLaunchToken = getPendingStartupLaunchToken(state, delivery.tabId) + if ( + queuedLaunchToken === undefined && + !hasRegisteredStartupLaunch(state, delivery.tabId, delivery.launchToken) + ) { + pendingAgentStartupDeliveries.delete(key) + } else { + flushPendingAgentStartupDeliveries() + } + stopPendingAgentStartupSubscriptionIfIdle() + }, 1000) + ) +} + +function clearStaleStartupRecheck(key: string): void { + const timer = staleStartupRecheckTimers.get(key) + if (!timer) { + return + } + globalThis.clearTimeout(timer) + staleStartupRecheckTimers.delete(key) +} diff --git a/src/renderer/src/lib/floating-terminal.test.ts b/src/renderer/src/lib/floating-terminal.test.ts new file mode 100644 index 00000000000..ea52f857a88 --- /dev/null +++ b/src/renderer/src/lib/floating-terminal.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + consumeFloatingTerminalOpenMaximizedIntent, + requestFloatingTerminalOpenMaximized +} from './floating-terminal' + +describe('floating terminal open-maximized intent', () => { + afterEach(() => { + vi.useRealTimers() + // Drain any leftover intent so it cannot bleed into an unrelated test. + consumeFloatingTerminalOpenMaximizedIntent() + }) + + it('returns true exactly once after a request', () => { + requestFloatingTerminalOpenMaximized() + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + // One-shot: a second consume without a new request is false. + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('returns false when no request was made', () => { + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('expires a stale intent so an abandoned open does not leak into a later open', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + // Why: the open was abandoned (prevented/interrupted before the panel + // mounted); a much-later ordinary open must not consume the stale intent. + vi.advanceTimersByTime(2001) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(false) + }) + + it('still honors an intent consumed within the same interaction window', () => { + vi.useFakeTimers() + requestFloatingTerminalOpenMaximized() + + vi.advanceTimersByTime(50) + + expect(consumeFloatingTerminalOpenMaximizedIntent()).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/floating-terminal.ts b/src/renderer/src/lib/floating-terminal.ts index 85a53f5d77f..b496590cff2 100644 --- a/src/renderer/src/lib/floating-terminal.ts +++ b/src/renderer/src/lib/floating-terminal.ts @@ -1 +1,28 @@ export const TOGGLE_FLOATING_TERMINAL_EVENT = 'orca-toggle-floating-terminal' + +// Why: maximize/restore lives in the panel's own keydown handler, but that +// handler is unmounted while the panel is closed. When Cmd+Opt+Shift+A is +// pressed with the panel closed, App opens it and records a one-shot intent +// here so the freshly mounted panel starts maximized instead of at its last +// saved size. A module singleton (not a prop) bridges the closed→mounted gap +// that React state cannot, and is consumed exactly once. +let openMaximizedIntentAt: number | null = null + +// Why: the panel mounts within the same interaction as the request, so an +// intent older than this window means the open was abandoned (prevented or +// interrupted before mount). Expiring it stops a stale intent from leaking +// into a later ordinary open and maximizing it unexpectedly. +const OPEN_MAXIMIZED_INTENT_TTL_MS = 2000 + +export function requestFloatingTerminalOpenMaximized(): void { + openMaximizedIntentAt = Date.now() +} + +export function consumeFloatingTerminalOpenMaximizedIntent(): boolean { + if (openMaximizedIntentAt === null) { + return false + } + const requestedAt = openMaximizedIntentAt + openMaximizedIntentAt = null + return Date.now() - requestedAt <= OPEN_MAXIMIZED_INTENT_TTL_MS +} diff --git a/src/renderer/src/lib/lazy-with-retry.test.ts b/src/renderer/src/lib/lazy-with-retry.test.ts index 7ef2f26fdec..a783a28e3a2 100644 --- a/src/renderer/src/lib/lazy-with-retry.test.ts +++ b/src/renderer/src/lib/lazy-with-retry.test.ts @@ -2,11 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ComponentType } from 'react' -import { loadLazyWithRetry } from './lazy-with-retry' +import { isLazyChunkLoadError, loadLazyWithRetry } from './lazy-with-retry' const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' const Comp: ComponentType = () => null const chunkParseError = (): SyntaxError => new SyntaxError("Unexpected token ']'") +const chunkFetchError = (): TypeError => + new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js') function spyOnReload(): ReturnType { const reload = vi.fn() @@ -107,21 +109,58 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe(false) }) - it('does NOT reload twice — re-throws once the guard is already set', async () => { + it('does NOT reload twice — wraps known chunk failures once the guard is already set', async () => { const reload = spyOnReload() window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') - const error = chunkParseError() + const error = chunkFetchError() const factory = vi.fn(() => Promise.reject(error)) const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + const assertion = expect(loaded).rejects.toMatchObject({ + name: 'LazyChunkLoadError', + cause: error + }) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(true) + }) + + it('preserves the original error when the guarded failure is not a dynamic import failure', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = new Error('render bug from lazy module evaluation') + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) const assertion = expect(loaded).rejects.toBe(error) await vi.advanceTimersByTimeAsync(5000) await assertion expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) - it('fails closed (re-throws, never reloads) when sessionStorage reads throw', async () => { + it('preserves bare parse errors so lazy module evaluation bugs still report', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) + }) + + it('fails closed with the original error when sessionStorage reads throw', async () => { const reload = spyOnReload() // Private-mode / sandboxed storage makes reads throw. The guard must treat // this as "already reloaded" so a broken chunk can NEVER cause a reload loop. @@ -135,6 +174,8 @@ describe('loadLazyWithRetry', () => { await assertion expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) it('records a lazy_chunk_reload breadcrumb (with reloadKey) before reloading', async () => { @@ -166,7 +207,7 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe(false) }) - it('re-throws without reloading when there is no window (SSR / node)', async () => { + it('re-throws the original error without reloading when there is no window (SSR / node)', async () => { vi.stubGlobal('window', undefined) const error = chunkParseError() const factory = vi.fn(() => Promise.reject(error)) @@ -177,6 +218,8 @@ describe('loadLazyWithRetry', () => { await assertion expect(factory).toHaveBeenCalledTimes(2) + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) it('keeps the reload guard set across a successful load (no second reload in one session)', async () => { diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts index 5a09f6b2a95..7c3cc604382 100644 --- a/src/renderer/src/lib/lazy-with-retry.ts +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -18,6 +18,8 @@ type AnyComponent = ComponentType type LazyFactory = () => Promise<{ default: T }> +type ReloadGuardState = 'not-attempted' | 'attempted' | 'unavailable' + export type LazyWithRetryOptions = { retries?: number baseDelayMs?: number @@ -25,6 +27,18 @@ export type LazyWithRetryOptions = { reloadKey?: string } +export class LazyChunkLoadError extends Error { + constructor(cause: unknown) { + super('Lazy chunk load failed after reload recovery was exhausted') + this.name = 'LazyChunkLoadError' + ;(this as { cause?: unknown }).cause = cause + } +} + +export function isLazyChunkLoadError(error: unknown): error is LazyChunkLoadError { + return error instanceof LazyChunkLoadError +} + // One recovery reload per session. The guard survives the reload itself (so we // never loop) but resets when the window/app closes, so a later launch — e.g. // after an update ships fresh chunks — can earn another reload. sessionStorage @@ -35,21 +49,26 @@ const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' const DEFAULT_RETRIES = 2 const DEFAULT_BASE_DELAY_MS = 250 -function hasAttemptedChunkReload(): boolean { +function readChunkReloadGuardState(): ReloadGuardState { + if (typeof window === 'undefined') { + return 'unavailable' + } try { - return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' + return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' ? 'attempted' : 'not-attempted' } catch { - // Why: when sessionStorage is unavailable (private mode / sandboxed), fail - // closed (treat as already-reloaded) so we never risk an infinite reload loop. - return true + // Why: when storage is blocked we cannot prove a reload happened, but still + // fail closed on reloads so a broken chunk never loops. + return 'unavailable' } } -function markChunkReloadAttempted(): void { +function markChunkReloadAttempted(): boolean { try { window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + return true } catch { - // Best-effort; if writing throws, hasAttemptedChunkReload() also fails closed. + // A reload without a durable guard can loop, so treat write failure as unavailable. + return false } } @@ -71,6 +90,24 @@ const wait = (ms: number): Promise => new Promise((resolve) => setTimeout( // down, so the error fallback never flashes in the moment before the reload lands. const SUSPEND_UNTIL_RELOAD = new Promise(() => undefined) +function isKnownDynamicImportFailure(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + if (error.name === 'ChunkLoadError') { + return true + } + + return [ + /failed to fetch dynamically imported module/i, + /error loading dynamically imported module/i, + /importing a module script failed/i, + /failed to load module script/i, + /loading chunk .+ failed/i + ].some((pattern) => pattern.test(error.message)) +} + export async function loadLazyWithRetry( factory: LazyFactory, options: LazyWithRetryOptions = {} @@ -91,8 +128,11 @@ export async function loadLazyWithRetry( } } - if (typeof window !== 'undefined' && !hasAttemptedChunkReload()) { - markChunkReloadAttempted() + const reloadGuardState = readChunkReloadGuardState() + if (typeof window !== 'undefined' && reloadGuardState === 'not-attempted') { + if (!markChunkReloadAttempted()) { + throw lastError + } recordReloadBreadcrumb( options.reloadKey ?? 'unknown', lastError instanceof Error ? lastError.message : String(lastError) @@ -101,8 +141,12 @@ export async function loadLazyWithRetry( return SUSPEND_UNTIL_RELOAD } - // Already reloaded once this session, or no window (SSR / node): re-throw so - // RecoverableRenderErrorBoundary catches and reports it instead of looping. + if (reloadGuardState === 'attempted' && isKnownDynamicImportFailure(lastError)) { + throw new LazyChunkLoadError(lastError) + } + + // No proven reload attempt (SSR / node / blocked storage) or unknown failure: + // re-throw the original error so normal error reporting semantics stay intact. throw lastError } diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 4626a06d259..d753e0977d0 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -1,27 +1,72 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockInspectRuntimeTerminalProcess, mockSendRuntimePtyInputVerified, - mockPasteDraftWhenAgentReady, + mockPasteDraftToAgentPtyWhenReady, mockTrack, - store + store, + storeListeners, + startupLeafId } = vi.hoisted(() => ({ mockInspectRuntimeTerminalProcess: vi.fn(), mockSendRuntimePtyInputVerified: vi.fn(), - mockPasteDraftWhenAgentReady: vi.fn(), + mockPasteDraftToAgentPtyWhenReady: vi.fn(), mockTrack: vi.fn(), + storeListeners: new Set<(state: unknown, previousState: unknown) => void>(), + startupLeafId: '11111111-1111-4111-8111-111111111111', store: { settings: {}, activeTabIdByWorktree: { 'wt-1': 'tab-1' } as Record, tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] } as Record, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record + ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, + pendingStartupByTabId: {} as Record, + terminalLayoutsByTabId: { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { '11111111-1111-4111-8111-111111111111': 'pty-1' } + } + } as Record< + string, + { + root: null + activeLeafId: null + expandedLeafId: null + ptyIdsByLeafId?: Record + } + >, + agentLaunchConfigByPaneKey: { + 'tab-1:11111111-1111-4111-8111-111111111111': { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { + tabId: 'tab-1', + leafId: '11111111-1111-4111-8111-111111111111', + launchToken: 'launch-token-1' + } + } + } as Record< + string, + { + launchConfig: { agentCommand: string; agentArgs: string; agentEnv: Record } + registeredAt: number + identity: { tabId?: string; leafId?: string; launchToken?: string } + } + > } })) vi.mock('@/store', () => ({ useAppStore: { - getState: () => store + getState: () => store, + subscribe: (listener: (state: unknown, previousState: unknown) => void) => { + storeListeners.add(listener) + return () => { + storeListeners.delete(listener) + } + } } })) @@ -31,7 +76,12 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ })) vi.mock('@/lib/agent-paste-draft', () => ({ - pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady + getSettingsForAgentTabRuntimeOwner: () => store.settings, + pasteDraftToAgentPtyWhenReady: mockPasteDraftToAgentPtyWhenReady +})) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => 'launch-token-1' })) vi.mock('@/lib/telemetry', () => ({ @@ -44,6 +94,7 @@ import { getWorkspaceSeedName, isGitLabIssueUrl } from './new-workspace' +import { resetAgentStartupDelayedDeliveryForTests } from './agent-startup-delayed-delivery' describe('getWorkspaceSeedName', () => { it('prefers an explicit name', () => { @@ -171,17 +222,40 @@ describe('isGitLabIssueUrl', () => { describe('ensureAgentStartupInTerminal prompt delivery', () => { beforeEach(() => { + vi.useRealTimers() vi.clearAllMocks() + storeListeners.clear() store.settings = {} store.activeTabIdByWorktree = { 'wt-1': 'tab-1' } store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } store.ptyIdsByTabId = { 'tab-1': ['pty-1'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-1' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } mockInspectRuntimeTerminalProcess.mockResolvedValue({ foregroundProcess: 'aider', hasChildProcesses: true }) mockSendRuntimePtyInputVerified.mockResolvedValue(true) - mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockPasteDraftToAgentPtyWhenReady.mockResolvedValue(true) + }) + + afterEach(() => { + vi.useRealTimers() + resetAgentStartupDelayedDeliveryForTests() }) it('sends a follow-up prompt through the terminal runtime without renderer telemetry', async () => { @@ -249,10 +323,12 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ tabId: 'tab-1', + ptyId: 'pty-1', content: 'review this before sending', - agent: 'claude' + agent: 'claude', + forcePaste: true }) expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) @@ -261,6 +337,21 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { store.activeTabIdByWorktree = { 'wt-1': 'setup-tab' } store.tabsByWorktree = { 'wt-1': [{ id: 'setup-tab' }, { id: 'agent-tab' }] } store.ptyIdsByTabId = { 'setup-tab': ['setup-pty'], 'agent-tab': ['agent-pty'] } + store.terminalLayoutsByTabId = { + 'agent-tab': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'agent-pty' } + } + } + store.agentLaunchConfigByPaneKey = { + [`agent-tab:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'agent-tab', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } await ensureAgentStartupInTerminal({ worktreeId: 'wt-1', @@ -275,12 +366,358 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { } }) - expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith({ + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ tabId: 'agent-tab', + ptyId: 'agent-pty', content: 'Linear context draft', - agent: 'codex' + agent: 'codex', + forcePaste: true }) }) + + it('pastes a draft after the seeded tab receives a delayed PTY', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'https://github.com/stablyai/orca/pull/2051' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + + store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-delayed' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + tabId: 'tab-1', + ptyId: 'pty-delayed', + content: 'https://github.com/stablyai/orca/pull/2051', + agent: 'codex', + forcePaste: true + }) + }) + + it('keeps waiting when a non-startup split PTY appears before the startup PTY', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + const splitLeafId = '22222222-2222-4222-8222-222222222222' + store.ptyIdsByTabId = { 'tab-1': ['split-pty'] } + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [splitLeafId]: 'split-pty' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${splitLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: splitLeafId, launchToken: 'other-token' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + + store.ptyIdsByTabId = { 'tab-1': ['split-pty', 'startup-pty'] } + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [splitLeafId]: 'split-pty', [startupLeafId]: 'startup-pty' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${splitLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: splitLeafId, launchToken: 'other-token' } + }, + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 2, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledWith({ + tabId: 'tab-1', + ptyId: 'startup-pty', + content: 'linked draft', + agent: 'codex', + forcePaste: true + }) + }) + + it('does not duplicate delayed delivery across repeated store updates', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-delayed' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + }) + + it('does not duplicate immediate delivery for the same launch token', async () => { + const startup = { + agent: 'codex' as const, + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft', + launchToken: 'launch-token-1' + } + + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + }) + + it('keeps one delayed subscription and tears it down after delivery drains', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-1' } } + const startup = { + agent: 'codex' as const, + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'linked draft', + launchToken: 'launch-token-1' + } + + const first = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + const second = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup + }) + + await vi.advanceTimersByTimeAsync(5_000) + await Promise.all([first, second]) + + expect(storeListeners.size).toBe(1) + + store.ptyIdsByTabId = { 'tab-1': ['pty-delayed'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-delayed' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-1' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).toHaveBeenCalledTimes(1) + expect(storeListeners.size).toBe(0) + }) + + it('does not let a newer same-tab launch satisfy an older pending delivery', async () => { + vi.useFakeTimers() + store.ptyIdsByTabId = {} + store.terminalLayoutsByTabId = {} + store.agentLaunchConfigByPaneKey = {} + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-old' } } + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + primaryTabId: 'tab-1', + startup: { + agent: 'codex', + launchCommand: 'codex', + expectedProcess: 'codex', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'old linked draft', + launchToken: 'launch-token-old' + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + store.pendingStartupByTabId = { 'tab-1': { launchToken: 'launch-token-new' } } + for (const listener of storeListeners) { + listener(store, store) + } + + store.ptyIdsByTabId = { 'tab-1': ['pty-new'] } + store.pendingStartupByTabId = {} + store.terminalLayoutsByTabId = { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { [startupLeafId]: 'pty-new' } + } + } + store.agentLaunchConfigByPaneKey = { + [`tab-1:${startupLeafId}`]: { + launchConfig: { agentCommand: 'codex', agentArgs: '', agentEnv: {} }, + registeredAt: 1, + identity: { tabId: 'tab-1', leafId: startupLeafId, launchToken: 'launch-token-old' } + } + } + for (const listener of storeListeners) { + listener(store, store) + } + + expect(mockPasteDraftToAgentPtyWhenReady).not.toHaveBeenCalled() + }) + + it('does not write a delayed follow-up prompt on readiness timeout', async () => { + vi.useFakeTimers() + mockInspectRuntimeTerminalProcess.mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + + const delivery = ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'aider', + launchCommand: 'aider', + expectedProcess: 'aider', + followupPrompt: 'fix the spinner', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + + await vi.advanceTimersByTimeAsync(5_000) + await delivery + + expect(mockSendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) }) describe('getSetupConfig', () => { diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 2402b68e84e..149c0e7708f 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -1,16 +1,21 @@ import { useAppStore } from '@/store' -import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { - inspectRuntimeTerminalProcess, - sendRuntimePtyInputVerified -} from '@/runtime/runtime-terminal-inspection' + getSettingsForAgentTabRuntimeOwner, + pasteDraftToAgentPtyWhenReady +} from '@/lib/agent-paste-draft' +import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' -import { isShellProcess } from '@/lib/tui-agent-startup' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' +import { + beginAgentStartupDeliveryAttempt, + getAgentStartupTabPtyId, + queuePendingAgentStartupDelivery, + resolveAgentStartupTabId +} from '@/lib/agent-startup-delayed-delivery' import type { FolderWorkspaceLinkedTask, OrcaHooks, TaskViewPresetId } from '../../../shared/types' import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy' -import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' import { slugifyForWorkspaceName } from '../../../shared/workspace-name' +import { createBrowserUuid } from '@/lib/browser-uuid' export { getLinkedWorkItemSuggestedName } from '../../../shared/workspace-name' export { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' export { getWorkspaceIntentName } from '../../../shared/workspace-name' @@ -244,6 +249,7 @@ export async function ensureAgentStartupInTerminal(args: { if (startup.followupPrompt === null && draftPrompt === null) { return } + const launchToken = ensureStartupLaunchToken(startup) // Why: poll until a terminal tab + PTY exists for the worktree before we // can interact with it. Activation creates the tab synchronously but the @@ -252,79 +258,79 @@ export async function ensureAgentStartupInTerminal(args: { let ptyId: string | null = null for (let attempt = 0; attempt < 30; attempt += 1) { if (attempt > 0) { - await new Promise((resolve) => window.setTimeout(resolve, 150)) + await new Promise((resolve) => globalThis.setTimeout(resolve, 150)) } const state = useAppStore.getState() - // Why: workspace activation tells us the exact tab that received the agent - // startup command. Use it for draft paste instead of re-deriving from - // active tab state, which can move while setup/background panes mount. - tabId = - primaryTabId ?? - state.activeTabIdByWorktree[worktreeId] ?? - state.tabsByWorktree[worktreeId]?.[0]?.id ?? - null + tabId = resolveAgentStartupTabId(state, worktreeId, primaryTabId) if (!tabId) { continue } - ptyId = state.ptyIdsByTabId[tabId]?.[0] ?? null + ptyId = getAgentStartupTabPtyId(state, tabId, launchToken) if (ptyId) { break } } if (!tabId || !ptyId) { + if (tabId) { + // Why: background-created workspaces can remain unmounted longer than a + // bounded readiness wait. Keep the draft/follow-up tied to the seeded + // tab until opening the workspace creates its PTY. + queuePendingAgentStartupDelivery({ + worktreeId, + tabId, + launchToken, + startup, + deliver: deliverAgentStartupToTerminal + }) + } return } + if (beginAgentStartupDeliveryAttempt({ worktreeId, tabId, launchToken })) { + await deliverAgentStartupToTerminal(tabId, ptyId, startup) + } +} + +async function deliverAgentStartupToTerminal( + tabId: string, + ptyId: string, + startup: AgentStartupPlan +): Promise { + const draftPrompt = startup.draftPrompt ?? null + const runtimeSettings = getSettingsForAgentTabRuntimeOwner(tabId) // Why: followupPrompt is the legacy path for stdin-after-start agents // (aider, goose, etc.) that need their initial prompt typed into the live // session and submitted. Wait until the agent owns the PTY before writing. if (startup.followupPrompt) { - await waitForAgentForeground(ptyId, startup.expectedProcess) - await sendFollowupPrompt(ptyId, startup.followupPrompt) + await sendFollowupPromptWhenAgentReady({ + ptyId, + expectedProcess: startup.expectedProcess, + prompt: startup.followupPrompt, + settings: runtimeSettings + }) } // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the // agent's input buffer (no per-char echo, no auto-submit). Shared with the // launch-work-item-direct flow so both behave identically. if (draftPrompt) { - await pasteDraftWhenAgentReady({ + await pasteDraftToAgentPtyWhenReady({ tabId, + ptyId, content: draftPrompt, - agent: startup.agent + agent: startup.agent, + // Why: startup.draftPrompt is only attached after native draft launch + // planning is unavailable, so this paste is the first delivery attempt. + forcePaste: true }) } } -async function sendFollowupPrompt(ptyId: string, prompt: string): Promise { - try { - return await sendRuntimePtyInputVerified(useAppStore.getState().settings, ptyId, `${prompt}\r`) - } catch { - return false - } -} - -// Why: legacy followupPrompt path used `agentOwnsForeground` exclusively (with -// a hasChildProcesses fallback after several polls). Preserve that behavior so -// stdin-after-start agents still receive their prompt under the same -// conditions. Returns when the agent appears ready or the budget expires. -async function waitForAgentForeground(ptyId: string, expectedProcess: string): Promise { - for (let attempt = 0; attempt < 30; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => window.setTimeout(resolve, 150)) - } - try { - const process = await inspectRuntimeTerminalProcess(useAppStore.getState().settings, ptyId) - const foreground = process.foregroundProcess?.toLowerCase() ?? '' - if (isExpectedAgentProcess(foreground, expectedProcess)) { - return - } - if (attempt >= 4 && !isShellProcess(foreground)) { - if (process.hasChildProcesses) { - return - } - } - } catch { - // Ignore transient PTY inspection failures and keep polling. - } +function ensureStartupLaunchToken(startup: AgentStartupPlan): string { + if (!startup.launchToken) { + // Why: delayed delivery retries must reuse the startup plan's launch token + // so they match the pane launch registration for this agent. + startup.launchToken = createBrowserUuid() } + return startup.launchToken } diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index bb24554e950..f6a2e367de0 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -6,7 +6,7 @@ import { markComplexScriptOutput, resetTerminalWebglSuggestion } from './pane-webgl-renderer' -import { openTerminal } from './pane-lifecycle' +import { attachLigatures, openTerminal } from './pane-lifecycle' import { buildDefaultTerminalOptions, DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, @@ -348,6 +348,18 @@ describe('attachWebgl', () => { }) }) +describe('attachLigatures', () => { + it('refreshes existing rows after loading the ligatures addon', () => { + const pane = createPane() + + attachLigatures(pane) + + expect(pane.terminal.loadAddon).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(pane.ligaturesAddon).not.toBeNull() + }) +}) + describe('openTerminal — Unicode 11 ordering', () => { beforeEach(() => { vi.stubGlobal('requestAnimationFrame', () => 1) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 66370b28546..f2fc5846add 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -286,6 +286,9 @@ export function attachLigatures(pane: ManagedPaneInternal): void { const ligaturesAddon = new LigaturesAddon() pane.terminal.loadAddon(ligaturesAddon) pane.ligaturesAddon = ligaturesAddon + // Why: ligatures can be enabled after rows already rendered, especially + // from Settings. Force existing glyph runs to be recomputed immediately. + pane.terminal.refresh(0, pane.terminal.rows - 1) // Why: the WebGL renderer builds its glyph texture atlas at activation // time, so `font-feature-settings` applied after WebGL loaded won't // reach the GPU-rendered cells until the atlas is rebuilt. The upstream diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 3c83982b53f..6b5d60f004c 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -71,6 +71,9 @@ export type WorktreeCreationRequest = { startupPlan: AgentStartupPlan | null quickPrompt: string quickTelemetry: AgentStartedTelemetry | null + /** When the composer stays open for sequential creates, completion must not + * steal focus from the next workspace name field. */ + suppressTerminalFocusOnCompletion?: boolean } /** Renderer-only, session-ephemeral record of an in-flight (or failed) worktree diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index fab3a7547f0..719c964676e 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -55,6 +55,36 @@ describe('getRuntimeEnvironmentIdForRepo', () => { ).toBe('focused-runtime') }) + it('uses the focused host row when duplicate repo ids exist', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBe('owner-runtime') + }) + + it('does not silently choose a duplicate repo row when the focused host does not match', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'other-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBe('other-runtime') + }) + it('returns settings scoped to an explicit local repo owner', () => { expect( getSettingsForRepoRuntimeOwner( diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index 81905e9d1b3..36b7cf757d2 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -1,4 +1,8 @@ -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + parseExecutionHostId +} from '../../../shared/execution-host' import type { GlobalSettings, Repo } from '../../../shared/types' export type RepoRuntimeOwnerState = { @@ -6,6 +10,26 @@ export type RepoRuntimeOwnerState = { settings?: Pick | null } +function findRepoOwner( + state: RepoRuntimeOwnerState, + repoId: string +): Pick | null { + const matchingRepos = state.repos?.filter((entry) => entry.id === repoId) ?? [] + if (matchingRepos.length === 0) { + return null + } + if (matchingRepos.length === 1) { + return matchingRepos[0] + } + const focusedHostId = getSettingsFocusedExecutionHostId(state.settings) + const focusedMatches = matchingRepos.filter( + (entry) => getRepoExecutionHostId(entry) === focusedHostId + ) + // Why: duplicate bare repo ids are only safe to route when focus selects one + // owner unambiguously; otherwise callers must avoid guessing a host. + return focusedMatches.length === 1 ? focusedMatches[0] : null +} + export function getRuntimeEnvironmentIdForRepo( state: RepoRuntimeOwnerState, repoId: string | null | undefined @@ -13,7 +37,7 @@ export function getRuntimeEnvironmentIdForRepo( if (!repoId) { return null } - const repo = state.repos?.find((entry) => entry.id === repoId) + const repo = findRepoOwner(state, repoId) const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) if (repo && hasExplicitOwner) { const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) diff --git a/src/renderer/src/lib/scroll-to-current-workspace-status.ts b/src/renderer/src/lib/scroll-to-current-workspace-status.ts index fbca39dc2a1..6f077da3b06 100644 --- a/src/renderer/src/lib/scroll-to-current-workspace-status.ts +++ b/src/renderer/src/lib/scroll-to-current-workspace-status.ts @@ -1,9 +1,15 @@ export const SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT = 'orca-scroll-to-current-workspace-reveal-request' -export type ScrollToCurrentWorkspaceRevealRequestDetail = { - beginRename?: boolean -} +export type ScrollToCurrentWorkspaceRevealRequestDetail = + | { + target?: { type: 'active-workspace' } + beginRename?: boolean + } + | { + target: { type: 'sidebar-row'; rowKey: string } + highlight?: boolean + } function dispatchScrollToCurrentWorkspaceReveal( detail?: ScrollToCurrentWorkspaceRevealRequestDetail @@ -21,5 +27,8 @@ export function requestScrollToCurrentWorkspaceReveal(): void { } export function requestScrollToCurrentWorkspaceRevealAndRename(): void { - dispatchScrollToCurrentWorkspaceReveal({ beginRename: true }) + dispatchScrollToCurrentWorkspaceReveal({ + target: { type: 'active-workspace' }, + beginRename: true + }) } diff --git a/src/renderer/src/lib/sidebar-worktree-activation.test.ts b/src/renderer/src/lib/sidebar-worktree-activation.test.ts index 8cbeab85a86..f0ea8dd30d0 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.test.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.test.ts @@ -1,45 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => { - const state = { - tabsByWorktree: {} as Record, - ptyIdsByTabId: {} as Record, - browserTabsByWorktree: {} as Record, - openFiles: [] as { worktreeId: string }[] - } - const activateAndRevealWorktree = vi.fn() - const activateAndRevealFolderWorkspace = vi.fn() - const markInputQuietSchedulerInput = vi.fn() - const pendingCallbacks: (() => void)[] = [] - const pendingCancels: ReturnType[] = [] - const scheduleAfterInputQuiet = vi.fn((callback: () => void) => { - let cancelled = false - const cancel = vi.fn(() => { - cancelled = true - }) - pendingCallbacks.push(() => { - if (!cancelled) { - callback() - } - }) - pendingCancels.push(cancel) - return cancel - }) - return { - activateAndRevealWorktree, - activateAndRevealFolderWorkspace, - markInputQuietSchedulerInput, - pendingCallbacks, - pendingCancels, - scheduleAfterInputQuiet, - state - } -}) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => mocks.state - } +const mocks = vi.hoisted(() => ({ + activateAndRevealFolderWorkspace: vi.fn(), + activateAndRevealWorktree: vi.fn() })) vi.mock('@/lib/worktree-activation', () => ({ @@ -47,54 +10,32 @@ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: mocks.activateAndRevealWorktree })) -vi.mock('@/lib/input-quiet-scheduler', () => ({ - markInputQuietSchedulerInput: mocks.markInputQuietSchedulerInput, - scheduleAfterInputQuiet: mocks.scheduleAfterInputQuiet -})) - -import { - activateWorktreeFromSidebar, - cancelPendingSidebarWorktreeActivation -} from './sidebar-worktree-activation' +import { activateWorktreeFromSidebar } from './sidebar-worktree-activation' describe('sidebar worktree activation', () => { beforeEach(() => { - delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ - cancelPendingSidebarWorktreeActivation() mocks.activateAndRevealWorktree.mockClear() mocks.activateAndRevealFolderWorkspace.mockClear() - mocks.markInputQuietSchedulerInput.mockClear() - mocks.scheduleAfterInputQuiet.mockClear() - mocks.pendingCallbacks.length = 0 - mocks.pendingCancels.length = 0 - mocks.state.tabsByWorktree = {} - mocks.state.ptyIdsByTabId = {} - mocks.state.browserTabsByWorktree = {} - mocks.state.openFiles = [] }) - it('cancels a queued slept-workspace activation', () => { - mocks.state.tabsByWorktree = { 'wt-parent': [{ id: 'tab-1' }] } - mocks.state.ptyIdsByTabId = { 'tab-1': [] } - - activateWorktreeFromSidebar('wt-parent') - cancelPendingSidebarWorktreeActivation() - - expect(mocks.pendingCancels[0]).toHaveBeenCalledTimes(1) - mocks.pendingCallbacks[0]?.() - expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - }) - - it('does not defer a workspace with a live PTY', () => { - mocks.state.tabsByWorktree = { 'wt-live': [{ id: 'tab-1' }] } - mocks.state.ptyIdsByTabId = { 'tab-1': ['pty-1'] } - + it('activates a clicked worktree immediately without sidebar reveal', () => { activateWorktreeFromSidebar('wt-live') - expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled() expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-live', { revealInSidebar: false }) + expect(mocks.activateAndRevealFolderWorkspace).not.toHaveBeenCalled() + }) + + it('does not defer slept worktree selection behind terminal wake work', () => { + activateWorktreeFromSidebar('wt-slept') + + // Why: setActiveWorktree already defers terminal prep where needed. The + // sidebar click itself must switch app state immediately. + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledTimes(1) + expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-slept', { + revealInSidebar: false + }) }) it('routes folder workspace activation through the guarded folder path', () => { @@ -102,19 +43,5 @@ describe('sidebar worktree activation', () => { expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-workspace-1') expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled() - expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled() - }) - - it('does not defer slept workspace activation in the web client', () => { - ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true - mocks.state.tabsByWorktree = { 'wt-web-slept': [{ id: 'tab-1' }] } - mocks.state.ptyIdsByTabId = { 'tab-1': [] } - - activateWorktreeFromSidebar('wt-web-slept') - - expect(mocks.scheduleAfterInputQuiet).not.toHaveBeenCalled() - expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-web-slept', { - revealInSidebar: false - }) }) }) diff --git a/src/renderer/src/lib/sidebar-worktree-activation.ts b/src/renderer/src/lib/sidebar-worktree-activation.ts index 8c7eec2df13..4c47fe18c1c 100644 --- a/src/renderer/src/lib/sidebar-worktree-activation.ts +++ b/src/renderer/src/lib/sidebar-worktree-activation.ts @@ -1,75 +1,17 @@ -import { useAppStore } from '@/store' import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation' -import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' import { parseWorkspaceKey } from '../../../shared/workspace-scope' -const SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS = 450 -const SLEPT_WORKTREE_ACTIVATION_IDLE_TIMEOUT_MS = 120 - -let pendingSidebarWorktreeActivation: { - worktreeId: string - cancel: () => void -} | null = null - -export function cancelPendingSidebarWorktreeActivation(): void { - pendingSidebarWorktreeActivation?.cancel() - pendingSidebarWorktreeActivation = null -} - -function shouldDeferSidebarWorktreeActivation(worktreeId: string): boolean { - // Why: web clients should activate immediately to avoid host/session churn and wake lag. - if ((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true) { - return false - } - const state = useAppStore.getState() - const tabs = state.tabsByWorktree[worktreeId] ?? [] - if (tabs.length === 0) { - return false - } - if ((state.browserTabsByWorktree[worktreeId] ?? []).length > 0) { - return false - } - if (state.openFiles.some((file) => file.worktreeId === worktreeId)) { - return false - } - return tabs.every((tab) => !tabHasLivePty(state.ptyIdsByTabId, tab.id)) -} - export function activateWorktreeFromSidebar(worktreeId: string): void { - cancelPendingSidebarWorktreeActivation() const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId) return } - const activate = (): void => { - if (pendingSidebarWorktreeActivation?.worktreeId === worktreeId) { - pendingSidebarWorktreeActivation = null - } - // Why: sidebar clicks already happen on a visible row; revealing again can - // jump duplicate pinned/canonical entries back to the first mounted copy. - activateAndRevealWorktree(worktreeId, { revealInSidebar: false }) - } - - if (!shouldDeferSidebarWorktreeActivation(worktreeId)) { - activate() - return - } - - markInputQuietSchedulerInput() - // Why: a slept workspace may remount terminals. Keep that work cancellable so - // a quick "changed my mind" click is never queued behind the first wake. - pendingSidebarWorktreeActivation = { - worktreeId, - cancel: scheduleAfterInputQuiet(activate, { - delayMs: 0, - quietMs: SLEPT_WORKTREE_ACTIVATION_INPUT_QUIET_MS, - idleTimeoutMs: SLEPT_WORKTREE_ACTIVATION_IDLE_TIMEOUT_MS - }) - } + // Why: sidebar clicks already happen on a visible row; revealing again can + // jump duplicate pinned/canonical entries back to the first mounted copy. + activateAndRevealWorktree(worktreeId, { revealInSidebar: false }) } diff --git a/src/renderer/src/lib/source-control-commit-draft-session.ts b/src/renderer/src/lib/source-control-commit-draft-session.ts new file mode 100644 index 00000000000..45811235d0a --- /dev/null +++ b/src/renderer/src/lib/source-control-commit-draft-session.ts @@ -0,0 +1,20 @@ +type CommitDraftsByWorktree = Record + +let sessionCommitDraftsByWorktree: CommitDraftsByWorktree = {} + +export function loadSessionCommitDrafts(): CommitDraftsByWorktree { + return sessionCommitDraftsByWorktree +} + +export function saveSessionCommitDrafts(nextDrafts: CommitDraftsByWorktree): void { + sessionCommitDraftsByWorktree = nextDrafts +} + +export function clearSessionCommitDraftForWorktree(worktreeId: string): void { + if (!(worktreeId in sessionCommitDraftsByWorktree)) { + return + } + const next = { ...sessionCommitDraftsByWorktree } + delete next[worktreeId] + sessionCommitDraftsByWorktree = next +} diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 052777529c4..872455495dc 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -32,6 +32,7 @@ function buildStartupOpt( command: plan.launchCommand, ...(plan.env ? { env: plan.env } : {}), launchConfig: plan.launchConfig, + ...(plan.launchToken ? { launchToken: plan.launchToken } : {}), ...(request.agent ? { launchAgent: request.agent } : {}), ...(plan.startupCommandDelivery ? { startupCommandDelivery: plan.startupCommandDelivery } : {}), // Why: command-code shows its prompt in the tab status before the first @@ -144,6 +145,11 @@ async function executeWorktreeCreation( } const backendSpawned = result.startupTerminal?.spawned === true + if (request.startupPlan && !backendSpawned && !request.startupPlan.launchToken) { + // Why: delayed delivery must target the exact pane spawned from this queued + // startup, so both halves of the handoff share one renderer-session token. + request.startupPlan.launchToken = createBrowserUuid() + } const startupOpt = buildStartupOpt(request, backendSpawned) if (worktree.path) { @@ -190,7 +196,7 @@ async function executeWorktreeCreation( startup: request.startupPlan }) } - if (stillActive) { + if (stillActive && !request.suppressTerminalFocusOnCompletion) { queueNewWorkspaceTerminalFocus(worktree.id, activation) } diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 849cb4dfa34..35bb8eb522c 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -3,6 +3,7 @@ import { getExplicitRuntimeEnvironmentIdForWorktree, getExecutionHostIdForWorktree, getRuntimeEnvironmentIdForWorktree, + getRuntimeSessionMirrorEnvironmentIds, getSettingsForWorktreeRuntimeOwner, type WorktreeRuntimeOwnerState } from './worktree-runtime-owner' @@ -118,3 +119,45 @@ describe('getExplicitRuntimeEnvironmentIdForWorktree', () => { ).toBe('runtime:worktree-env') }) }) + +describe('getRuntimeSessionMirrorEnvironmentIds', () => { + it('includes focused runtime plus explicit repo, worktree, and folder owners', () => { + const multiRuntimeState: WorktreeRuntimeOwnerState = { + ...state, + worktreesByRepo: { + ...state.worktreesByRepo, + 'runtime-repo': [ + ...(state.worktreesByRepo?.['runtime-repo'] ?? []), + { + id: 'runtime-repo::wt-runtime-override', + repoId: 'runtime-repo', + hostId: 'runtime:worktree-env' + } + ] + } + } + + expect(getRuntimeSessionMirrorEnvironmentIds(multiRuntimeState)).toEqual([ + 'focused-env', + 'folder-env', + 'owner-env', + 'worktree-env' + ]) + }) + + it('does not include local or SSH owners', () => { + const localOnlyState: WorktreeRuntimeOwnerState = { + settings: { activeRuntimeEnvironmentId: null }, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'ssh-repo', connectionId: 'remote', executionHostId: 'ssh:remote' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-local', repoId: 'local-repo', hostId: 'local' }], + 'ssh-repo': [{ id: 'ssh-repo::wt-ssh', repoId: 'ssh-repo', hostId: 'ssh:remote' }] + } + } + + expect(getRuntimeSessionMirrorEnvironmentIds(localOnlyState)).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index e8c0e50f795..be54a7b9eb2 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -162,6 +162,35 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( return getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo)) } +export function getRuntimeSessionMirrorEnvironmentIds(state: WorktreeRuntimeOwnerState): string[] { + const ids = new Set() + const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() + if (activeRuntimeEnvironmentId) { + ids.add(activeRuntimeEnvironmentId) + } + for (const repo of state.repos ?? []) { + const environmentId = getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo)) + if (environmentId) { + ids.add(environmentId) + } + } + for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { + for (const worktree of worktrees) { + const environmentId = getRuntimeEnvironmentIdFromWorktreeHost(worktree.hostId) + if (environmentId) { + ids.add(environmentId) + } + } + } + for (const group of state.projectGroups ?? []) { + const environmentId = getExplicitRuntimeEnvironmentIdFromHost(group.executionHostId) + if (environmentId) { + ids.add(environmentId) + } + } + return [...ids].sort() +} + export function getExecutionHostIdForWorktree( state: WorktreeRuntimeOwnerState, worktreeId: string | null | undefined diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 8dabf867757..d4f1f57abc4 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -132,6 +132,37 @@ describe('runtime git client', () => { }) }) + it('forwards upstream-negative-cache bypass to local git status only when enabled', async () => { + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: true } + ) + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: false } + ) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined + }) + }) + it('checks ignored paths through local git IPC', async () => { gitCheckIgnored.mockResolvedValue(['dist/bundle.js']) @@ -262,6 +293,31 @@ describe('runtime git client', () => { }) }) + it('forwards upstream-negative-cache bypass through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', bypassEffectiveUpstreamNegativeCache: true }, + timeoutMs: 15_000 + }) + }) + it('checks ignored paths through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index f7c9c02f06d..b0795718ac2 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -121,21 +121,29 @@ export function getRuntimeGitScope( export async function getRuntimeGitStatus( context: RuntimeGitContext, - options?: { includeIgnored?: boolean } + options?: { includeIgnored?: boolean; bypassEffectiveUpstreamNegativeCache?: boolean } ): Promise { const target = getActiveRuntimeTarget(context.settings) const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {} if (target.kind === 'local' || !context.worktreeId) { return window.api.git.status({ worktreePath: context.worktreePath, connectionId: context.connectionId, - ...includeIgnoredArgs + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs }) } return callRuntimeRpc( target, 'git.status', - { worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs }, + { + worktree: toRuntimeWorktreeSelector(context.worktreeId), + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs + }, { timeoutMs: 15_000 } ) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 90293c7ec7f..687b18e24fa 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -306,19 +306,17 @@ describe('applyWebSessionTabsSnapshot', () => { ).toBe(false) }) - it('only starts the all-session mirror for paired web clients', () => { + it('starts the all-session mirror for desktop and paired web clients', () => { expect( shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: ENV, - workspaceSessionReady: true, - isWebClient: true + workspaceSessionReady: true }) ).toBe(true) expect( shouldSyncAllRuntimeSessionTabs({ activeRuntimeEnvironmentId: ENV, - workspaceSessionReady: true, - isWebClient: false + workspaceSessionReady: false }) ).toBe(false) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 43fc267001c..8ec5c326daf 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -32,7 +32,10 @@ import type { OpenFile } from '../store/slices/editor' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream' import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getRuntimeSessionMirrorEnvironmentIds +} from '@/lib/worktree-runtime-owner' import { createWebRuntimeSessionTerminal, HOST_TERMINAL_SURFACE_SEPARATOR, @@ -255,10 +258,9 @@ export function shouldSyncRuntimeSessionTabs(args: { export function shouldSyncAllRuntimeSessionTabs(args: { activeRuntimeEnvironmentId: string | null | undefined workspaceSessionReady: boolean - isWebClient: boolean }): boolean { const environmentId = args.activeRuntimeEnvironmentId?.trim() - return Boolean(environmentId && args.workspaceSessionReady && args.isWebClient) + return Boolean(environmentId && args.workspaceSessionReady) } export function resetWebSessionTabsSnapshotFreshnessForTests(): void { @@ -2328,134 +2330,142 @@ export function applyFreshWebSessionTabsSnapshots( export function useWebSessionTabsSync(): void { const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) - const activeRuntimeEnvironmentId = useAppStore( - (state) => state.settings?.activeRuntimeEnvironmentId ?? null + const runtimeSessionMirrorEnvironmentKey = useAppStore((state) => + getRuntimeSessionMirrorEnvironmentIds(state).join('\u0000') ) const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId) ) const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady) - const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true useEffect(() => { - const environmentId = activeRuntimeEnvironmentId?.trim() + const environmentIds = runtimeSessionMirrorEnvironmentKey + ? runtimeSessionMirrorEnvironmentKey.split('\u0000').filter((id) => id.trim()) + : [] // Why: startup hydration writes browser-local session state; applying the // host snapshot before that point gets clobbered and leaves the sidebar stale. - // Desktop clients should not mirror every remote session just because a - // remote is connected; project discovery runs through separate repo APIs. - if ( - !shouldSyncAllRuntimeSessionTabs({ - activeRuntimeEnvironmentId, - workspaceSessionReady, - isWebClient - }) || - !environmentId - ) { + // Selectedness is not liveness: desktop and web clients both mirror the + // runtime's session bindings so background worktrees do not look asleep. + if (!workspaceSessionReady || environmentIds.length === 0) { return } let disposed = false - let unsubscribe: (() => void) | null = null + const unsubscribes: (() => void)[] = [] // Why: the streaming RPC emits an initial snapshots event, but startup can // render a paired web session before that event is applied. A one-shot // fetch makes initial parity deterministic; the stream remains the live // update path afterward. - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'session.tabs.listAll', - params: {}, - timeoutMs: 15_000 - }) - .then((response: RuntimeRpcResponse) => { - if (disposed) { - return - } - if (response.ok === false) { - console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) - return - } - const result = response.result - if (!isSessionTabsListAllResult(result)) { - console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') - return - } - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId) - ) - }) - .catch((error) => { - if (!disposed) { - console.warn( - '[web-session-tabs-sync] failed to load initial session tabs:', - error instanceof Error ? error.message : String(error) - ) - } - }) - - void window.api.runtimeEnvironments - .subscribe( - { + for (const environmentId of environmentIds) { + if ( + !shouldSyncAllRuntimeSessionTabs({ + activeRuntimeEnvironmentId: environmentId, + workspaceSessionReady + }) + ) { + continue + } + void window.api.runtimeEnvironments + .call({ selector: environmentId, - method: 'session.tabs.subscribeAll', + method: 'session.tabs.listAll', params: {}, timeoutMs: 15_000 - }, - { - onResponse: (response: RuntimeRpcResponse) => { - if (disposed) { - return - } - if (response.ok === false) { - console.warn( - '[web-session-tabs-sync] global subscription failed:', - response.error.message - ) - return - } - const event = response.result as SessionTabsStreamEvent - if (event.type === 'snapshots') { - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) - ) - return - } - if (event.type !== 'snapshot' && event.type !== 'updated') { - return - } - useAppStore.setState((state) => - applyFreshWebSessionTabsSnapshot(state, event, environmentId) - ) - }, - onError: (error) => { - console.warn('[web-session-tabs-sync] global subscription error:', error.message) + }) + .then((response: RuntimeRpcResponse) => { + if (disposed) { + return } - } - ) - .then((handle) => { - if (disposed) { - handle.unsubscribe() - return - } - unsubscribe = handle.unsubscribe - }) - .catch((error) => { - if (!disposed) { - console.warn( - '[web-session-tabs-sync] failed to subscribe globally:', - error instanceof Error ? error.message : String(error) + if (response.ok === false) { + console.warn('[web-session-tabs-sync] initial listAll failed:', response.error.message) + return + } + const result = response.result + if (!isSessionTabsListAllResult(result)) { + console.warn('[web-session-tabs-sync] initial listAll returned an invalid payload') + return + } + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshots(state, result.snapshots, environmentId) ) - } - }) + }) + .catch((error) => { + if (!disposed) { + console.warn( + '[web-session-tabs-sync] failed to load initial session tabs:', + error instanceof Error ? error.message : String(error) + ) + } + }) + + void window.api.runtimeEnvironments + .subscribe( + { + selector: environmentId, + method: 'session.tabs.subscribeAll', + params: {}, + timeoutMs: 15_000 + }, + { + onResponse: (response: RuntimeRpcResponse) => { + if (disposed) { + return + } + if (response.ok === false) { + console.warn( + '[web-session-tabs-sync] global subscription failed:', + response.error.message + ) + return + } + const event = response.result as SessionTabsStreamEvent + if (event.type === 'snapshots') { + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshots(state, event.snapshots, environmentId) + ) + return + } + if (event.type !== 'snapshot' && event.type !== 'updated') { + return + } + useAppStore.setState((state) => + applyFreshWebSessionTabsSnapshot(state, event, environmentId) + ) + }, + onError: (error) => { + console.warn('[web-session-tabs-sync] global subscription error:', error.message) + } + } + ) + .then((handle) => { + if (disposed) { + handle.unsubscribe() + return + } + unsubscribes.push(handle.unsubscribe) + }) + .catch((error) => { + if (!disposed) { + console.warn( + '[web-session-tabs-sync] failed to subscribe globally:', + error instanceof Error ? error.message : String(error) + ) + } + }) + } return () => { disposed = true - unsubscribe?.() + for (const unsubscribe of unsubscribes) { + unsubscribe() + } // Why: environment ids can churn as paired runtimes reconnect or switch; // stale freshness/mapping entries should not live for the renderer lifetime. - clearWebSessionTabsTrackingForEnvironment(environmentId) + for (const environmentId of environmentIds) { + clearWebSessionTabsTrackingForEnvironment(environmentId) + } } - }, [activeRuntimeEnvironmentId, isWebClient, workspaceSessionReady]) + }, [runtimeSessionMirrorEnvironmentKey, workspaceSessionReady]) useEffect(() => { const environmentId = activeWorktreeRuntimeEnvironmentId?.trim() diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 6423b997134..315f4740310 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -11,7 +11,12 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { GitStatusEntry, Tab } from '../../../../shared/types' +import type { + GitBranchChangeEntry, + GitBranchCompareSummary, + GitStatusEntry, + Tab +} from '../../../../shared/types' const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() @@ -2648,6 +2653,80 @@ describe('createEditorSlice combined diff exclusions', () => { }) ) }) + + it('includes untracked files in the all changes snapshot', () => { + const store = createEditorStore() + const stagedEntry: GitStatusEntry = { + path: 'src/staged.ts', + status: 'modified', + area: 'staged' + } + const untrackedEntry: GitStatusEntry = { + path: 'src/new.ts', + status: 'untracked', + area: 'untracked' + } + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [stagedEntry, untrackedEntry] + }) + store.getState().openAllDiffs('wt-1', '/repo') + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + id: 'wt-1::all-diffs::uncommitted', + uncommittedEntriesSnapshot: [stagedEntry, untrackedEntry] + }) + ) + }) + + it('opens all changes with uncommitted and committed branch snapshots', () => { + const store = createEditorStore() + const localEntry: GitStatusEntry = { + path: 'src/local.ts', + status: 'modified', + area: 'unstaged' + } + const branchEntry: GitBranchChangeEntry = { + path: 'src/committed.ts', + status: 'modified' + } + const branchSummary: GitBranchCompareSummary = { + baseRef: 'origin/main', + baseOid: 'base-oid', + compareRef: 'HEAD', + headOid: 'head-oid', + mergeBase: 'merge-base-oid', + changedFiles: 1, + status: 'ready' + } + + store.getState().setGitStatus('wt-1', { + conflictOperation: 'unknown', + entries: [localEntry] + }) + store.setState({ + gitBranchCompareSummaryByWorktree: { 'wt-1': branchSummary }, + gitBranchChangesByWorktree: { 'wt-1': [branchEntry] } + }) + store.getState().openAllDiffs('wt-1', '/repo') + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ + id: 'wt-1::all-diffs::uncommitted', + diffSource: 'combined-all', + uncommittedEntriesSnapshot: [localEntry], + branchEntriesSnapshot: [branchEntry], + branchCompare: expect.objectContaining({ + baseRef: 'origin/main', + baseOid: 'base-oid', + headOid: 'head-oid', + mergeBase: 'merge-base-oid' + }) + }) + ) + }) }) describe('createEditorSlice remote branch actions', () => { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index fa2d9ea1d63..213f575e25e 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -94,6 +94,7 @@ export type DiffSource = | 'staged' | 'branch' | 'commit' + | 'combined-all' | 'combined-uncommitted' | 'combined-branch' | 'combined-commit' @@ -155,7 +156,7 @@ type CommitCompareLike = Pick< } type CombinedDiffAlternate = { - source: 'combined-uncommitted' | 'combined-branch' + source: 'combined-all' | 'combined-branch' branchCompare?: BranchCompareSnapshot } @@ -294,6 +295,7 @@ type EditorOpenTargetOptions = { type GitRuntimeOperationOptions = { runtimeTargetSettings?: Pick | null + applyUpstreamStatus?: boolean } export type PendingEditorReveal = { @@ -595,7 +597,7 @@ export type EditorSlice = { connectionId?: string, pushTarget?: GitPushTarget, options?: GitRuntimeOperationOptions - ) => Promise + ) => Promise pushBranch: ( worktreeId: string, worktreePath: string, @@ -2657,13 +2659,22 @@ export const createEditorSlice: StateCreator = (s ] ?? 'All Changes') : 'All Changes' set((s) => { + const branchSummary = s.gitBranchCompareSummaryByWorktree[worktreeId] + const branchCompare = + !areaFilter && + branchSummary?.status === 'ready' && + branchSummary.baseOid && + branchSummary.headOid && + branchSummary.mergeBase + ? toBranchCompareSnapshot(branchSummary) + : undefined + const branchEntriesSnapshot = branchCompare + ? (s.gitBranchChangesByWorktree[worktreeId] ?? []) + : undefined const relevantEntries = entriesSnapshot ?? (s.gitStatusByWorktree[worktreeId] ?? []).filter((entry) => { - if (areaFilter) { - return entry.area === areaFilter - } - return entry.area !== 'untracked' + return areaFilter === undefined || entry.area === areaFilter }) const skippedConflicts = relevantEntries .filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind) @@ -2687,6 +2698,9 @@ export const createEditorSlice: StateCreator = (s f.id === id ? { ...f, + diffSource: branchCompare ? 'combined-all' : 'combined-uncommitted', + branchCompare, + branchEntriesSnapshot, uncommittedEntriesSnapshot, combinedAlternate: alternate, combinedAreaFilter: areaFilter, @@ -2710,7 +2724,9 @@ export const createEditorSlice: StateCreator = (s language: 'plaintext', isDirty: false, mode: 'diff', - diffSource: 'combined-uncommitted', + diffSource: branchCompare ? 'combined-all' : 'combined-uncommitted', + branchCompare, + branchEntriesSnapshot, uncommittedEntriesSnapshot, combinedAlternate: alternate, combinedAreaFilter: areaFilter, @@ -3525,7 +3541,10 @@ export const createEditorSlice: StateCreator = (s }, pushTarget ) - get().setUpstreamStatus(worktreeId, status) + if (options?.applyUpstreamStatus !== false) { + get().setUpstreamStatus(worktreeId, status) + } + return status } catch (error) { // Why: on error we leave the prior status in place rather than writing a // synthetic {hasUpstream:false} — that would flash 'Publish Branch' on a @@ -3534,6 +3553,7 @@ export const createEditorSlice: StateCreator = (s // genuinely newly unpublished, the polling effect will eventually correct // the status on success. console.error('fetchUpstreamStatus failed', error) + return null } }, pushBranch: async ( diff --git a/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts new file mode 100644 index 00000000000..e2a0839f9ee --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { createGitHubSlice } from './github' +import { createHostedReviewSlice } from './hosted-review' +import type { AppState } from '../types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' + +// Why: regression guard for the renderer OOM crash. Enqueuing the local +// `gh:enqueuePRRefresh` for a repo owned by a remote/SSH/runtime host rejects +// with "Access denied: unknown repository path"; a flood of those failures grew +// the renderer heap to the V8 ceiling and crashed it. enqueueGitHubPRRefresh +// must only hit the local handler for local-host repos. +const enqueuePRRefresh = vi.fn().mockResolvedValue(undefined) + +const mockApi = { + gh: { + prForBranch: vi.fn().mockResolvedValue(null), + enqueuePRRefresh, + issue: vi.fn().mockResolvedValue(null) + }, + hostedReview: { forBranch: vi.fn().mockResolvedValue(null) }, + runtimeEnvironments: { call: vi.fn() } +} + +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } + +function createTestStore() { + return create()( + (...a) => + ({ + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a) + }) as AppState + ) +} + +function seed(store: ReturnType, repo: Record) { + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + repos: [repo], + worktreesByRepo: { + [repo.id as string]: [ + { + id: 'wt-1', + repoId: repo.id, + path: `${repo.path}/wt`, + branch: 'refs/heads/feature', + displayName: 'feature', + isMainWorktree: false, + isBare: false, + isArchived: false, + linkedPR: null, + linkedIssue: null + } + ] + }, + prCache: {}, + issueCache: {}, + hostedReviewCache: {}, + sshConnectionStates: new Map() + } as unknown as Partial) +} + +describe('enqueueGitHubPRRefresh host guard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('enqueues the local handler for a local-host repo', () => { + const store = createTestStore() + seed(store, { id: 'local-1', path: '/Users/me/code/local-1', name: 'local-1', kind: 'git' }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).toHaveBeenCalledTimes(1) + }) + + it('enqueues the local handler for a repo with an explicit local executionHostId', () => { + const store = createTestStore() + seed(store, { + id: 'local-2', + path: '/Users/me/code/local-2', + name: 'local-2', + kind: 'git', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).toHaveBeenCalledTimes(1) + }) + + it('does NOT enqueue the local handler for a runtime-host repo (the OOM loop)', () => { + const store = createTestStore() + seed(store, { + id: 'rt-1', + path: '/Users/lobster/orca/workspaces/openclaw/imessage-performance', + name: 'imessage-performance', + kind: 'git', + executionHostId: 'runtime:env-1' + }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).not.toHaveBeenCalled() + }) + + it('does NOT enqueue the local handler for an SSH repo', () => { + const store = createTestStore() + seed(store, { + id: 'ssh-1', + path: '/home/me/code/ssh-1', + name: 'ssh-1', + kind: 'git', + connectionId: 'conn-1' + }) + + store.getState().enqueueGitHubPRRefresh('wt-1', 'active', 80) + + expect(enqueuePRRefresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts new file mode 100644 index 00000000000..d53beb70488 --- /dev/null +++ b/src/renderer/src/store/slices/github-pr-refresh-owner-routing.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { createGitHubSlice } from './github' +import { createHostedReviewSlice } from './hosted-review' +import type { AppState } from '../types' +import type { PRInfo, Repo, Worktree } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const enqueuePRRefresh = vi.fn().mockResolvedValue(undefined) +const reportVisiblePRRefreshCandidates = vi.fn().mockResolvedValue(true) + +const mockApi = { + gh: { + prForBranch: vi.fn().mockResolvedValue(null), + refreshPRNow: vi.fn().mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 }), + enqueuePRRefresh, + reportVisiblePRRefreshCandidates, + issue: vi.fn().mockResolvedValue(null) + }, + hostedReview: { forBranch: vi.fn().mockResolvedValue(null) }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + cache: { + getGitHub: vi.fn().mockResolvedValue(null), + setGitHub: vi.fn().mockResolvedValue(undefined) + } +} + +// @ts-expect-error test window mock +globalThis.window = { api: mockApi } + +function resetRuntimeMocks(): void { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) +} + +function createTestStore() { + return create()( + (...a) => + ({ + ...createGitHubSlice(...a), + ...createHostedReviewSlice(...a) + }) as AppState + ) +} + +function makePR(overrides: Partial = {}): PRInfo { + return { + number: 12, + title: 'Test PR', + state: 'open', + url: 'https://example.com/pr/12', + checksStatus: 'pending', + updatedAt: '2026-03-28T00:00:00Z', + mergeable: 'UNKNOWN', + headSha: 'head-oid', + ...overrides + } +} + +function makeRepo(overrides: Partial & Pick): Repo { + return { + displayName: overrides.id, + badgeColor: 'blue', + addedAt: 1, + kind: 'git', + ...overrides + } +} + +function makeWorktree(repoId: string, branch: string, id = `${repoId}-wt`): Worktree { + return { + id, + repoId, + path: `/worktrees/${id}`, + head: 'head-oid', + branch, + displayName: branch, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + isMainWorktree: false, + isBare: false, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 1 + } +} + +function seed( + store: ReturnType, + state: Pick & Partial +): void { + store.setState({ + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + groupBy: 'pr-status', + worktreeCardProperties: ['status'], + prCache: {}, + issueCache: {}, + hostedReviewCache: {}, + commentsCache: {}, + sshConnectionStates: new Map(), + ...state + } as unknown as Partial) +} + +describe('GitHub PR refresh owner-host routing', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRuntimeMocks() + }) + + it('routes explicit PR refresh for a runtime-owned repo to its owner while Local desktop is active', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 23 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/runtime-owner' + seed(store, { + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + repos: [ + makeRepo({ + id: 'repo-runtime', + path: repoPath, + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] + } + }) + + store.getState().enqueueGitHubPRRefresh('wt-runtime', 'active', 80) + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + timeoutMs: 30_000 + }) + }) + + it('keeps connected SSH PR refresh on the local coordinator even when a runtime is focused', () => { + const store = createTestStore() + const repoPath = '/ssh/repo' + const branch = 'feature/ssh' + seed(store, { + settings: { activeRuntimeEnvironmentId: 'env-focused' } as AppState['settings'], + repos: [ + makeRepo({ + id: 'repo-ssh', + path: repoPath, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + ], + sshConnectionStates: new Map([ + ['ssh-1', { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 }] + ]), + worktreesByRepo: { + 'repo-ssh': [makeWorktree('repo-ssh', branch, 'wt-ssh')] + } + }) + + store.getState().refreshGitHubForWorktreeIfStale('wt-ssh') + + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + expect(enqueuePRRefresh).toHaveBeenCalledWith({ + candidate: expect.objectContaining({ + repoId: 'repo-ssh', + repoPath, + branch, + connectionId: 'ssh-1', + connectionState: 'connected' + }), + reason: 'active', + priority: 80 + }) + }) + + it('routes post-push refresh for a runtime-owned repo to its owner while Local desktop is active', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 24 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoPath = '/runtime/repo' + const branch = 'feature/post-push' + seed(store, { + repos: [ + makeRepo({ + id: 'repo-runtime', + path: repoPath, + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')] + } + }) + + store.getState().refreshGitHubForWorktree('wt-runtime') + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(enqueuePRRefresh).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch, linkedPRNumber: null }, + timeoutMs: 30_000 + }) + }) + + it('splits visible candidates between local coordinator and runtime owner', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-1', + ok: true, + result: makePR({ number: 25 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + seed(store, { + repos: [ + makeRepo({ id: 'repo-local', path: '/local/repo' }), + makeRepo({ + id: 'repo-runtime', + path: '/runtime/repo', + executionHostId: 'runtime:env-1' + }) + ], + worktreesByRepo: { + 'repo-local': [makeWorktree('repo-local', 'feature/local', 'wt-local')], + 'repo-runtime': [makeWorktree('repo-runtime', 'feature/runtime', 'wt-runtime')] + } + }) + + store.getState().reportVisibleGitHubPRRefreshCandidates(['wt-local', 'wt-runtime'], 123) + + await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1)) + expect(reportVisiblePRRefreshCandidates).toHaveBeenCalledWith({ + candidates: [ + expect.objectContaining({ + repoId: 'repo-local', + repoPath: '/local/repo', + branch: 'feature/local' + }) + ], + generation: 123 + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'github.prForBranch', + params: { repo: 'repo-runtime', branch: 'feature/runtime', linkedPRNumber: null }, + timeoutMs: 30_000 + }) + }) +}) diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 372ddfd9bb4..823ed97e0ec 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -2967,6 +2967,75 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { }) }) + it('does not direct-fetch when enqueue returns an automatic validation skip', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ + kind: 'skipped', + skippedReason: 'validation-denied' + }) + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false + } + ] + } + } as unknown as Partial) + + store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80) + await Promise.resolve() + + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1) + expect(mockApi.gh.prForBranch).not.toHaveBeenCalled() + }) + + it('direct-fetches when enqueue returns an explicit fallback result', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ kind: 'fallback' }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: 1 }) + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false + } + ] + } + } as unknown as Partial) + + store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80) + await Promise.resolve() + await Promise.resolve() + + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1) + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledTimes(1) + }) + it('enqueues active PR refresh with a GitHub hosted-review fallback number', () => { const store = createTestStore() const repoPath = '/repo' diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 039550b7299..495eaef72b7 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -130,6 +130,46 @@ function getRuntimeRepoTarget( return repo ? { target, repo } : null } +function getPRRefreshOwnerRuntimeEnvironmentId( + candidate: Pick +): string | null { + const parsed = parseExecutionHostId(candidate.executionHostId) + if (parsed?.kind === 'runtime') { + return parsed.environmentId + } + const cacheScope = candidate.cacheKey.split('::', 1)[0] + const cacheScopeHost = parseExecutionHostId(cacheScope) + return cacheScopeHost?.kind === 'runtime' ? cacheScopeHost.environmentId : null +} + +function getPRRefreshRuntimeRepoTarget( + state: AppState, + candidate: GitHubPRRefreshCandidate +): { target: { kind: 'environment'; environmentId: string }; repo: Repo } | null { + const ownerRuntimeEnvironmentId = getPRRefreshOwnerRuntimeEnvironmentId(candidate) + if (!ownerRuntimeEnvironmentId) { + return null + } + // Why: PR refreshes must follow the repo owner host, not the Active Server + // dropdown. A runtime-owned worktree can be visible while Local desktop is focused. + return getRuntimeRepoTarget( + state, + candidate.repoPath, + state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: ownerRuntimeEnvironmentId } as AppState['settings']) + ) +} + +function shouldEnqueueLocalPRRefresh(candidate: GitHubPRRefreshCandidate): boolean { + // Why: the local PR coordinator owns local git and SSH bridge refreshes, but + // runtime-owned repos and disconnected SSH repos must not hit the IPC crash path. + if (getPRRefreshOwnerRuntimeEnvironmentId(candidate) !== null) { + return false + } + return !candidate.connectionId || candidate.connectionState === 'connected' +} + type GitHubWorkItemRequestContext = { repoId: string repoPath: string @@ -3383,7 +3423,7 @@ export const createGitHubSlice: StateCreator = (s if (!candidate) { return } - if (getRuntimeRepoTarget(state, candidate.repoPath)) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, @@ -3394,11 +3434,14 @@ export const createGitHubSlice: StateCreator = (s }) return } + if (!shouldEnqueueLocalPRRefresh(candidate)) { + return + } const enqueue = window.api.gh.enqueuePRRefresh if (enqueue) { void enqueue({ candidate, reason, priority }) .then((queued) => { - if (queued === false) { + if (queued === false || queued.kind === 'fallback') { return get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, @@ -3424,8 +3467,9 @@ export const createGitHubSlice: StateCreator = (s return worktree ? buildPRRefreshCandidate(state, worktree) : null }) .filter((candidate): candidate is GitHubPRRefreshCandidate => candidate !== null) - if (getActiveRuntimeTarget(state.settings).kind === 'environment') { - for (const candidate of candidates) { + const localCandidates: GitHubPRRefreshCandidate[] = [] + for (const candidate of candidates) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { repoId: candidate.repoId, worktreeId: candidate.worktreeId, @@ -3433,12 +3477,15 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) + continue + } + if (shouldEnqueueLocalPRRefresh(candidate)) { + localCandidates.push(candidate) } - return } const reportVisible = window.api.gh.reportVisiblePRRefreshCandidates if (reportVisible) { - void reportVisible({ candidates, generation }).catch((err) => { + void reportVisible({ candidates: localCandidates, generation }).catch((err) => { console.warn('Failed to report visible PR refresh candidates:', err) }) } @@ -3733,7 +3780,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else { + } else if (shouldEnqueueLocalPRRefresh(candidate)) { void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'swr', priority: 10 }) } } @@ -3797,7 +3844,7 @@ export const createGitHubSlice: StateCreator = (s if (!worktree.isBare && branch) { const candidate = buildPRRefreshCandidate(get(), worktree) if (candidate) { - if (getRuntimeRepoTarget(get(), candidate.repoPath)) { + if (getPRRefreshRuntimeRepoTarget(get(), candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: true, repoId: candidate.repoId, @@ -3806,7 +3853,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else { + } else if (shouldEnqueueLocalPRRefresh(candidate)) { void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'post-push', priority: 100 }) } } @@ -3995,7 +4042,7 @@ export const createGitHubSlice: StateCreator = (s if (shouldRefreshPR && !worktree.isBare && branch) { const candidate = buildPRRefreshCandidate(state, worktree) if (candidate) { - if (getRuntimeRepoTarget(state, candidate.repoPath)) { + if (getPRRefreshRuntimeRepoTarget(state, candidate)) { void get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: true, repoId: candidate.repoId, @@ -4004,7 +4051,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber: candidate.fallbackPRNumber ?? null, fallbackPRSource: candidate.fallbackPRSource ?? null }) - } else { + } else if (shouldEnqueueLocalPRRefresh(candidate)) { void window.api.gh.enqueuePRRefresh?.({ candidate, reason: 'active', priority: 80 }) } } diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts index ebe427f443b..f1dabcb24ca 100644 --- a/src/renderer/src/store/slices/jira.test.ts +++ b/src/renderer/src/store/slices/jira.test.ts @@ -391,14 +391,30 @@ describe('createJiraSlice credential errors', () => { }) }) - it('keeps Jira connected when an issue read hits endpoint-level forbidden access', async () => { + it('surfaces endpoint-level forbidden errors without disconnecting Jira', async () => { const store = createTestStore() store.setState({ jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } }) jiraListIssues.mockRejectedValueOnce(new Error('Forbidden')) - await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toEqual([]) + // A non-auth failure must reject so the Tasks panel can show a real error + // instead of a misleading empty list, while keeping the session connected. + await expect(store.getState().listJiraIssues('assigned', 30)).rejects.toThrow('Forbidden') + + expect(store.getState().jiraStatus.connected).toBe(true) + }) + + it('surfaces endpoint-level search errors without disconnecting Jira', async () => { + const store = createTestStore() + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } + }) + jiraSearchIssues.mockRejectedValueOnce(new Error('Malformed JQL')) + + await expect(store.getState().searchJiraIssues('project =', 30)).rejects.toThrow( + 'Malformed JQL' + ) expect(store.getState().jiraStatus.connected).toBe(true) }) diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index 1498f312edd..fd084a3d985 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -496,7 +496,14 @@ export const createJiraSlice: StateCreator = (set, ) { set({ jiraStatus: { connected: false, viewer: null } }) } - return [] + // Credential/auth failures are surfaced through connection state, so they + // keep the empty-list contract. Other failures (forbidden, bad JQL, + // network, 5xx) reject so the Tasks panel can show a real error instead + // of a misleading "No issues found". + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + return [] + } + throw error }) .finally(() => { if (inflightSearchRequests.get(cacheKey) === entry) { @@ -583,7 +590,14 @@ export const createJiraSlice: StateCreator = (set, ) { set({ jiraStatus: { connected: false, viewer: null } }) } - return [] + // Credential/auth failures are surfaced through connection state, so they + // keep the empty-list contract. Other failures (forbidden, bad JQL, + // network, 5xx) reject so the Tasks panel can show a real error instead + // of a misleading "No issues found". + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { + return [] + } + throw error }) .finally(() => { if (inflightListRequests.get(cacheKey) === entry) { diff --git a/src/renderer/src/store/slices/repo-host-identity.ts b/src/renderer/src/store/slices/repo-host-identity.ts new file mode 100644 index 00000000000..92afdafc3ad --- /dev/null +++ b/src/renderer/src/store/slices/repo-host-identity.ts @@ -0,0 +1,56 @@ +import type { GlobalSettings, Repo } from '../../../../shared/types' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +type RepoIdentityParts = Pick + +export function getRepoHostIdentity(repo: RepoIdentityParts): string { + return getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) +} + +export function getRepoHostIdentityForParts(repoId: string, hostId: string): string { + // Why: host ids and repo ids can contain punctuation; NUL keeps the composite + // key collision-free without escaping user/provider-owned strings. + return `${hostId}\0${repoId}` +} + +export function repoMatchesHostIdentity( + repo: RepoIdentityParts, + repoId: string, + hostId: string +): boolean { + return repo.id === repoId && getRepoExecutionHostId(repo) === hostId +} + +export function findRepoForHost( + repos: readonly RepoIdentityParts[], + repoId: string, + options: { + hostId?: ExecutionHostId | string | null + settings?: Pick | null + } = {} +): RepoIdentityParts | null { + const matchingRepos = repos.filter((repo) => repo.id === repoId) + if (matchingRepos.length === 0) { + return null + } + + if (options.hostId) { + return matchingRepos.find((repo) => getRepoExecutionHostId(repo) === options.hostId) ?? null + } + + if (matchingRepos.length === 1) { + return matchingRepos[0] + } + + const focusedHostId = getSettingsFocusedExecutionHostId(options.settings) + const focusedMatches = matchingRepos.filter( + (repo) => getRepoExecutionHostId(repo) === focusedHostId + ) + // Why: when duplicate ids exist even within the focused host, mutating by bare + // id would be ambiguous. Let callers surface no owner instead of guessing. + return focusedMatches.length === 1 ? focusedMatches[0] : null +} diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts index 71615147156..f8c334ff4a7 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import fs from 'node:fs' import type { Repo } from '../../../../shared/types' import { reconcileFetchedRepos } from './repo-identity-reconcile' @@ -47,4 +48,34 @@ describe('reconcileFetchedRepos', () => { expect(result[0]).toBe(previous[0]) expect(result.map((r) => r.id)).toEqual(['a', 'b']) }) + + it('preserves same-id repo identity only within the matching host', () => { + const previous = [ + makeRepo('same', { executionHostId: 'local', displayName: 'Local' }), + makeRepo('same', { executionHostId: 'runtime:env-1', displayName: 'Remote' }) + ] + const next = [ + makeRepo('same', { executionHostId: 'runtime:env-1', displayName: 'Remote' }), + makeRepo('same', { executionHostId: 'local', displayName: 'Local' }) + ] + + const result = reconcileFetchedRepos(previous, next) + + expect(result[0]).toBe(previous[1]) + expect(result[1]).toBe(previous[0]) + }) + + it('uses map-style host identity lookups for large duplicate refreshes', () => { + const previous = Array.from({ length: 1000 }, (_, index) => + makeRepo(`repo-${index}`, { executionHostId: index % 2 === 0 ? 'local' : 'runtime:env-1' }) + ) + const next = previous.map((repo) => ({ ...repo })) + + const result = reconcileFetchedRepos(previous, next) + + expect(result).toBe(previous) + expect(result.every((repo, index) => repo === previous[index])).toBe(true) + const source = fs.readFileSync(new URL('./repo-identity-reconcile.ts', import.meta.url), 'utf8') + expect(source).not.toMatch(/previous\.find|next\.find|findIndex/) + }) }) diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index 55d313e0cd6..119c4ed3e2a 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -1,4 +1,5 @@ import type { Repo } from '../../../../shared/types' +import { getRepoHostIdentity } from './repo-host-identity' // Why: after a drag-reorder we optimistically set `repos`, persist, and main // broadcasts `repos:changed`. The renderer's own echo handler refetches, which @@ -27,10 +28,10 @@ function areReposEqual(a: Repo, b: Repo): boolean { } export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): Repo[] { - const previousById = new Map(previous.map((repo) => [repo.id, repo])) + const previousById = new Map(previous.map((repo) => [getRepoHostIdentity(repo), repo])) let identical = next.length === previous.length const reconciled = next.map((repo, index) => { - const existing = previousById.get(repo.id) + const existing = previousById.get(getRepoHostIdentity(repo)) if (existing && areReposEqual(existing, repo)) { if (existing !== previous[index]) { identical = false 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 index 8ba15f5edd9..fd4ace117f3 100644 --- a/src/renderer/src/store/slices/repo-reorder-host-split.test.ts +++ b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts @@ -43,4 +43,15 @@ describe('splitRepoReorderByHost', () => { }) expect(groups).toEqual([{ hostId: 'local', orderedIds: ['a'] }]) }) + + it('routes duplicate bare ids by occurrence in current repo order', () => { + const repos = [repo('same', 'local'), repo('same', 'runtime:env-1')] + const groups = splitRepoReorderByHost(['same', 'same'], repos, { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([ + { hostId: 'local', orderedIds: ['same'] }, + { hostId: 'runtime:env-1', orderedIds: ['same'] } + ]) + }) }) diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.ts b/src/renderer/src/store/slices/repo-reorder-host-split.ts index 2ef975b4646..9924df17c7f 100644 --- a/src/renderer/src/store/slices/repo-reorder-host-split.ts +++ b/src/renderer/src/store/slices/repo-reorder-host-split.ts @@ -9,6 +9,11 @@ export type RepoReorderHostGroup = { orderedIds: string[] } +type RepoHostCursor = { + hosts: string[] + nextIndex: number +} + /** 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 @@ -25,14 +30,26 @@ export function splitRepoReorderByHost( settings: Pick | null | undefined ): RepoReorderHostGroup[] { const focusedHostId = getSettingsFocusedExecutionHostId(settings) - const hostByRepoId = new Map() + const remainingHostsByRepoId = new Map() for (const repo of repos) { const hasExplicitOwner = Boolean(repo.executionHostId?.trim() || repo.connectionId?.trim()) - hostByRepoId.set(repo.id, hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId) + const hostId = hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId + const existing = remainingHostsByRepoId.get(repo.id) + if (existing) { + existing.hosts.push(hostId) + } else { + remainingHostsByRepoId.set(repo.id, { hosts: [hostId], nextIndex: 0 }) + } } const groups = new Map() for (const id of orderedIds) { - const hostId = hostByRepoId.get(id) + const remainingHosts = remainingHostsByRepoId.get(id) + // Why: a bare repo id can appear once per host in the combined order. The + // cursor consumes each occurrence against its owner host exactly once. + const hostId = remainingHosts?.hosts[remainingHosts.nextIndex] + if (remainingHosts) { + remainingHosts.nextIndex += 1 + } if (!hostId) { continue } diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts index 902f808fc7f..6471776b327 100644 --- a/src/renderer/src/store/slices/repos-all-hosts.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -188,6 +188,103 @@ beforeEach(() => { }) }) +function configureSharedProjectCompatibilityMocks( + options: { + localRepoHasProviderIdentity?: boolean + remoteProjectRuntimePreference?: Project['localWindowsRuntimePreference'] + } = {} +): { + sharedProjectId: string + sharedRemoteProject: Project +} { + const sharedProjectId = 'github:stablyai/orca' + const localRepoForSharedProject: Repo = + options.localRepoHasProviderIdentity === false + ? localRepo + : { + ...localRepo, + upstream: { owner: 'stablyai', repo: 'orca' } + } + const remoteRepoWithIdentity: Repo = { + ...remoteRepo, + upstream: { owner: 'stablyai', repo: 'orca' } + } + const sharedLocalProject: Project = { + id: sharedProjectId, + displayName: 'Orca', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 1, + updatedAt: 1 + } + const sharedRemoteProject: Project = { + id: sharedProjectId, + displayName: 'Orca', + badgeColor: '#111', + sourceRepoIds: ['remote-repo'], + ...(options.remoteProjectRuntimePreference + ? { localWindowsRuntimePreference: options.remoteProjectRuntimePreference } + : {}), + createdAt: 2, + updatedAt: 2 + } + const sharedLocalSetup: ProjectHostSetup = { + ...localProjectHostSetup, + projectId: sharedProjectId + } + const sharedRemoteSetup: ProjectHostSetup = { + ...localProjectHostSetup, + id: 'remote-setup', + projectId: sharedProjectId, + repoId: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote setup' + } + reposList.mockResolvedValue([localRepoForSharedProject]) + projectsList.mockResolvedValue([sharedLocalProject]) + listHostSetups.mockResolvedValue([sharedLocalSetup]) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepoWithIdentity] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list', + ok: true, + result: { projects: [sharedRemoteProject] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list', + ok: true, + result: { setups: [sharedRemoteSetup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + return { sharedProjectId, sharedRemoteProject } +} + +function expectSharedProjectMetadata(projects: readonly Project[], sharedProjectId: string): void { + const sharedProject = projects.find((project) => project.id === sharedProjectId) + expect([...(sharedProject?.sourceRepoIds ?? [])].sort()).toEqual(['local-repo', 'remote-repo']) + expect(sharedProject?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) +} + describe('fetchReposForAllHosts', () => { it('loads local + all configured runtime environments even when a remote env is active', async () => { // Why: a cold start that restored a remote workspace leaves the remote @@ -207,6 +304,366 @@ describe('fetchReposForAllHosts', () => { expect(store.getState().projectHostSetups).toContainEqual(localProjectHostSetup) }) + it('preserves runtime-owned project metadata during a local-only repo refresh', async () => { + const runtimeProject: Project = { + id: 'repo:remote-repo', + displayName: 'Runtime API project', + badgeColor: '#abc', + sourceRepoIds: ['remote-repo'], + createdAt: 3, + updatedAt: 4 + } + const runtimeSetup: ProjectHostSetup = { + ...localProjectHostSetup, + id: 'runtime-setup', + projectId: runtimeProject.id, + repoId: 'remote-repo', + path: '/srv/repo', + displayName: 'Runtime setup', + setupState: 'ready', + createdAt: 3, + updatedAt: 4 + } + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list', + ok: true, + result: { projects: [runtimeProject] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list', + ok: true, + result: { setups: [runtimeSetup] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + await store.getState().fetchReposForAllHosts() + await store.getState().fetchRepos() + + expect( + store + .getState() + .projects.map((project) => project.id) + .sort() + ).toEqual(['local-project', 'repo:remote-repo']) + expect(store.getState().projects.find((project) => project.id === runtimeProject.id)).toEqual( + expect.objectContaining({ + displayName: runtimeProject.displayName, + badgeColor: runtimeProject.badgeColor + }) + ) + }) + + it('does not backfill another host repo-derived project during runtime-only refresh', async () => { + const store = createTestStore() + + await store.getState().fetchReposForAllHosts() + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect( + store + .getState() + .projects.map((project) => project.id) + .sort() + ).toEqual(['local-project', 'repo:remote-repo']) + expect(store.getState().projects).toContainEqual(localProject) + }) + + it('preserves shared project metadata when the same project id is fetched from multiple hosts', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + expect(store.getState().projectHostSetups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'local', + repoId: 'local-repo' + }), + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'runtime:env-1', + repoId: 'remote-repo' + }) + ]) + ) + }) + + it('keeps local Windows runtime preference when remote project metadata has its own preference', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks({ + remoteProjectRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect( + store.getState().projects.find((project) => project.id === sharedProjectId) + ?.localWindowsRuntimePreference + ).toEqual({ kind: 'windows-host' }) + }) + + it('preserves shared project metadata after a runtime-only repo refresh', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + }) + + it('keeps the local side of a shared project when a runtime refresh removes its repos', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list-empty', + ok: true, + result: { repos: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list-empty', + ok: true, + result: { projects: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list-empty', + ok: true, + result: { setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + const sharedProject = store + .getState() + .projects.find((project) => project.id === sharedProjectId) + expect(sharedProject?.sourceRepoIds).toEqual(['local-repo']) + expect(sharedProject?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'local', + repoId: 'local-repo' + }) + ]) + }) + + it('preserves API-owned local shared projects when repo identity cannot re-derive them', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks({ + localRepoHasProviderIdentity: false + }) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list-empty', + ok: true, + result: { repos: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list-empty', + ok: true, + result: { projects: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list-empty', + ok: true, + result: { setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect(store.getState().projects.map((project) => project.id)).toEqual([sharedProjectId]) + expect( + store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + ).toEqual(['local-repo']) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'local', + repoId: 'local-repo' + }) + ]) + }) + + it('drops refreshed-host source ownership when that repo no longer matches a shared project', async () => { + const { sharedProjectId } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list-reassigned', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list-empty', + ok: true, + result: { projects: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list-empty', + ok: true, + result: { setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect( + store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + ).toEqual(['local-repo']) + expect( + store + .getState() + .projects.map((project) => project.id) + .sort() + ).toEqual(['github:stablyai/orca', 'repo:remote-repo']) + expect(store.getState().projectHostSetups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: sharedProjectId, + hostId: 'local', + repoId: 'local-repo' + }), + expect.objectContaining({ + projectId: 'repo:remote-repo', + hostId: 'runtime:env-1', + repoId: 'remote-repo' + }) + ]) + ) + }) + + it('drops stale runtime repo ownership when project metadata lags behind repo removal', async () => { + const { sharedProjectId, sharedRemoteProject } = configureSharedProjectCompatibilityMocks() + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list-empty', + ok: true, + result: { repos: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list-stale', + ok: true, + result: { projects: [sharedRemoteProject] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-project-host-setup-list-empty', + ok: true, + result: { setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + } + }) + + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect( + store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + ).toEqual(['local-repo']) + }) + it('fails soft when a runtime environment is unreachable, keeping local repos', async () => { runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { if (args.method === 'repo.list') { diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts new file mode 100644 index 00000000000..22690115f32 --- /dev/null +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +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' + +const localDuplicate: Repo = { + id: 'same-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local' +} + +const remoteDuplicate: Repo = { + id: 'same-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2, + executionHostId: 'runtime:env-1' +} + +const reposRemove = vi.fn() +const reposUpdate = vi.fn() +const reposReorder = vi.fn() +const ptyKill = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function projectHostSetup(overrides: Pick): ProjectHostSetup { + return { + projectId: 'repo:same-repo', + repoId: 'same-repo', + path: '/same-repo', + displayName: 'Same Repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposRemove.mockReset() + reposUpdate.mockReset() + reposReorder.mockReset() + ptyKill.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + remove: reposRemove, + update: reposUpdate, + reorder: reposReorder + }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice host identity routing', () => { + it('updates only the focused host row when repo ids are duplicated across hosts', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName: 'Remote Renamed' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate] + }) + + await store.getState().updateRepo('same-repo', { displayName: 'Remote Renamed' }) + + expect(store.getState().repos).toEqual([ + localDuplicate, + { ...remoteDuplicate, displayName: 'Remote Renamed' } + ]) + expect(reposUpdate).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote Renamed' } }, + timeoutMs: 15_000 + }) + }) + + it('updates a legacy local duplicate without overwriting an explicit remote sibling', async () => { + const { executionHostId: _executionHostId, ...legacyLocalDuplicate } = localDuplicate + reposUpdate.mockResolvedValue(undefined) + const store = createTestStore() + store.setState({ repos: [legacyLocalDuplicate as Repo, remoteDuplicate] }) + + await store.getState().updateRepo('same-repo', { displayName: 'Local Renamed' }) + + expect(store.getState().repos).toEqual([ + { ...legacyLocalDuplicate, displayName: 'Local Renamed' }, + remoteDuplicate + ]) + expect(reposUpdate).toHaveBeenCalledWith({ + repoId: 'same-repo', + updates: { displayName: 'Local Renamed' } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'repo.update' }) + ) + }) + + it('keeps queued focused-host repo updates pinned when focus changes', async () => { + const firstUpdate = deferred<{ + id: string + ok: true + result: { repo: Repo } + _meta: { runtimeId: string } + }>() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.update') { + const { updates } = (args as unknown as { params: { updates: { displayName: string } } }) + .params + const displayName = updates.displayName + if (displayName === 'Remote slow') { + return firstUpdate.promise + } + return Promise.resolve({ + id: 'rpc-queued-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName } }, + _meta: { runtimeId: 'runtime-remote' } + }) + } + return Promise.resolve({ + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate] + }) + + const first = store.getState().updateRepo('same-repo', { displayName: 'Remote slow' }) + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote slow' } } + }) + ) + }) + + const second = store.getState().updateRepo('same-repo', { displayName: 'Remote queued' }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-2' } as never }) + firstUpdate.resolve({ + id: 'rpc-first-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName: 'Remote slow' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + + await expect(first).resolves.toBe(true) + await expect(second).resolves.toBe(true) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote queued' } } + }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'repo.update' }) + ) + expect(store.getState().repos).toEqual([ + localDuplicate, + { ...remoteDuplicate, displayName: 'Remote queued' } + ]) + }) + + it('removes only the focused host row and worktrees for duplicate repo ids', async () => { + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo' + }) + const remoteWorktree = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + repos: [localDuplicate, remoteDuplicate], + projects: [ + { + id: 'repo:same-repo', + displayName: 'Same Repo', + badgeColor: '#000', + sourceRepoIds: ['same-repo'], + createdAt: 1, + updatedAt: 1 + } satisfies Project + ], + projectHostSetups: [ + projectHostSetup({ id: 'local-setup', hostId: 'local' }), + projectHostSetup({ id: 'remote-setup', hostId: 'runtime:env-1' }) + ], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + tabsByWorktree: { + [localWorktree.id]: [{ id: 'local-tab', worktreeId: localWorktree.id }] as never, + [remoteWorktree.id]: [{ id: 'remote-tab', worktreeId: remoteWorktree.id }] as never + }, + ptyIdsByTabId: { + 'local-tab': ['local-pty'], + 'remote-tab': ['remote-pty'] + }, + lastVisitedAtByWorktreeId: { + [localWorktree.id]: 10, + [remoteWorktree.id]: 20 + } + }) + + await store.getState().removeProject('same-repo') + + expect(store.getState().repos).toEqual([remoteDuplicate]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([remoteWorktree]) + expect(store.getState().tabsByWorktree[localWorktree.id]).toBeUndefined() + expect(store.getState().tabsByWorktree[remoteWorktree.id]).toEqual([ + { id: 'remote-tab', worktreeId: remoteWorktree.id } + ]) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ hostId: 'runtime:env-1', repoId: 'same-repo' }) + ]) + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [remoteWorktree.id]: 20 }) + expect(store.getState().projects).toEqual([ + expect.objectContaining({ id: 'repo:same-repo', sourceRepoIds: ['same-repo'] }) + ]) + expect(reposRemove).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(ptyKill).toHaveBeenCalledWith('local-pty') + expect(ptyKill).not.toHaveBeenCalledWith('remote-pty') + }) + + it('removes a runtime duplicate without purging legacy local worktrees', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-remove', + ok: true, + result: { status: 'removed' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo' + }) + const remoteWorktree = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + tabsByWorktree: { + [localWorktree.id]: [{ id: 'local-tab', worktreeId: localWorktree.id }] as never, + [remoteWorktree.id]: [{ id: 'remote-tab', worktreeId: remoteWorktree.id }] as never + }, + ptyIdsByTabId: { + 'local-tab': ['local-pty'], + 'remote-tab': ['remote-pty'] + }, + lastVisitedAtByWorktreeId: { + [localWorktree.id]: 10, + [remoteWorktree.id]: 20 + } + }) + + await store.getState().removeProject('same-repo') + + expect(store.getState().repos).toEqual([localDuplicate]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) + expect(store.getState().tabsByWorktree[localWorktree.id]).toEqual([ + { id: 'local-tab', worktreeId: localWorktree.id } + ]) + expect(store.getState().tabsByWorktree[remoteWorktree.id]).toBeUndefined() + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktree.id]: 10 }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.rm', + params: { repo: 'same-repo' }, + timeoutMs: 15_000 + }) + expect(reposRemove).not.toHaveBeenCalled() + expect(ptyKill).toHaveBeenCalledWith('remote-pty') + expect(ptyKill).not.toHaveBeenCalledWith('local-pty') + }) + + it('reorders duplicate repo ids once per owning host', async () => { + reposReorder.mockResolvedValue({ status: 'applied' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-reorder', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ repos: [localDuplicate, remoteDuplicate] }) + + await store.getState().reorderRepos(['same-repo', 'same-repo']) + + expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate]) + expect(reposReorder).toHaveBeenCalledWith({ orderedIds: ['same-repo'] }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: ['same-repo'] }, + timeoutMs: 15_000 + }) + }) +}) diff --git a/src/renderer/src/store/slices/repos-project-runtime.test.ts b/src/renderer/src/store/slices/repos-project-runtime.test.ts index d3fefb1d8c4..f007158d31b 100644 --- a/src/renderer/src/store/slices/repos-project-runtime.test.ts +++ b/src/renderer/src/store/slices/repos-project-runtime.test.ts @@ -135,6 +135,47 @@ describe('repo slice project runtime updates', () => { expect(window.api.projects.listHostSetups).toHaveBeenCalled() }) + it('clears stale local runtime preferences when local project refresh omits them', async () => { + const staleProject: Project = { + id: 'local-project', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 1, + updatedAt: 1 + } + const refreshedProject: Project = { + id: staleProject.id, + displayName: staleProject.displayName, + badgeColor: staleProject.badgeColor, + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 2 + } + const setup: ProjectHostSetup = { + id: 'setup-1', + projectId: staleProject.id, + hostId: 'local', + repoId: 'local-repo', + path: '/local', + displayName: 'Local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + projectsList.mockResolvedValue([refreshedProject]) + window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) + reposList.mockResolvedValue([localRepo]) + const store = createTestStore() + store.setState({ projects: [staleProject], projectHostSetups: [setup] }) + + await store.getState().fetchRepos() + + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + }) + it('updates local project runtime preferences through the projects API', async () => { const project: Project = { id: 'project-1', @@ -224,4 +265,61 @@ describe('repo slice project runtime updates', () => { }) expect(projectsUpdate).not.toHaveBeenCalled() }) + + it('preserves shared project source repos when updating local runtime preferences', async () => { + const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#000', + sourceRepoIds: ['local-repo', 'remote-repo'], + createdAt: 1, + updatedAt: 2 + } + projectsUpdate.mockResolvedValue({ + ...project, + sourceRepoIds: ['local-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + updatedAt: 3 + }) + const store = createTestStore() + store.setState({ projects: [project] }) + + await store.getState().updateProject(project.id, { + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + + expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + kind: 'windows-host' + }) + }) + + it('clears local runtime preferences without dropping shared project source repos', async () => { + const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#000', + sourceRepoIds: ['local-repo', 'remote-repo'], + localWindowsRuntimePreference: { kind: 'windows-host' }, + createdAt: 1, + updatedAt: 2 + } + projectsUpdate.mockResolvedValue({ + id: project.id, + displayName: project.displayName, + badgeColor: project.badgeColor, + sourceRepoIds: ['local-repo'], + createdAt: project.createdAt, + updatedAt: 3 + }) + const store = createTestStore() + store.setState({ projects: [project] }) + + await store.getState().updateProject(project.id, { + localWindowsRuntimePreference: undefined + }) + + expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) + expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index a8a55aeb60d..16258886392 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -44,9 +44,14 @@ import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' 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 { splitRepoReorderByHost } from './repo-reorder-host-split' +import { + findRepoForHost, + getRepoHostIdentity, + getRepoHostIdentityForParts, + repoMatchesHostIdentity +} from './repo-host-identity' import { assertRuntimeEnvironmentCapability, callRuntimeRpc, @@ -56,7 +61,6 @@ import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-clien import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { translate } from '@/i18n/i18n' @@ -187,13 +191,21 @@ function getRepoUpdateChains(get: () => AppState): Map> return chains } -function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] { +function worktreeBelongsToHost(worktree: { hostId?: string }, hostId: string): boolean { + return (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId +} + +function getKnownRepoWorktreeIds(state: AppState, projectId: string, hostId?: string): string[] { const ids = new Set() for (const worktree of state.worktreesByRepo[projectId] ?? []) { - ids.add(worktree.id) + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } } for (const worktree of state.detectedWorktreesByRepo[projectId]?.worktrees ?? []) { - ids.add(worktree.id) + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } } return [...ids] } @@ -394,6 +406,152 @@ function projectCompatibilityFromRepos( } } +function mergeProjectCompatibilityProject(base: Project, overlay: Project): Project { + const localWindowsRuntimePreference = + 'localWindowsRuntimePreference' in overlay + ? overlay.localWindowsRuntimePreference + : base.localWindowsRuntimePreference + const project: Project = { + ...base, + ...overlay, + // Why: all-host startup fetches hosts separately; one host's project record + // must not erase repo ownership learned from another host with the same id. + sourceRepoIds: [...new Set([...base.sourceRepoIds, ...overlay.sourceRepoIds])], + createdAt: Math.min(base.createdAt, overlay.createdAt), + updatedAt: Math.max(base.updatedAt, overlay.updatedAt) + } + if (localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = localWindowsRuntimePreference + } + return project +} + +function mergeProjectCompatibilityProjects( + base: readonly Project[], + overlay: readonly Project[] +): Project[] { + 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] = mergeProjectCompatibilityProject(merged[index]!, entry) + } + } + return merged +} + +function mergeUpdatedProjectCompatibilityProject( + base: Project, + updated: Project, + updates: ProjectUpdate +): Project { + const project = mergeProjectCompatibilityProject(base, updated) + if ('localWindowsRuntimePreference' in updates) { + const localWindowsRuntimePreference = + 'localWindowsRuntimePreference' in updated + ? updated.localWindowsRuntimePreference + : updates.localWindowsRuntimePreference + // Why: project.update returns one host's project record, but preference + // clears must still override the cross-host metadata preservation merge. + if (localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = localWindowsRuntimePreference + } + } + return project +} + +function getCurrentSourceRepoIds(project: Project, currentRepoIds: ReadonlySet): string[] { + return project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) +} + +function getReposById(repos: readonly Repo[]): Map { + const reposById = new Map() + for (const repo of repos) { + const existing = reposById.get(repo.id) + if (existing) { + existing.push(repo) + } else { + reposById.set(repo.id, [repo]) + } + } + return reposById +} + +function getSourceRepoIdsOutsideHost( + project: Project, + reposById: ReadonlyMap, + hostId: string +): string[] { + return project.sourceRepoIds.filter((repoId) => { + const repos = reposById.get(repoId) ?? [] + return repos.some((repo) => getRepoExecutionHostId(repo) !== hostId) + }) +} + +function getMergedSourceRepoIdsForHostRefresh( + previous: Project, + current: Project, + reposById: ReadonlyMap, + hostId: string +): string[] { + return [ + ...new Set([ + ...getSourceRepoIdsOutsideHost(previous, reposById, hostId), + ...getCurrentSourceRepoIds(current, new Set(reposById.keys())) + ]) + ] +} + +function projectWithCurrentSourceRepoIds( + project: Project, + currentRepoIds: ReadonlySet +): Project { + const sourceRepoIds = getCurrentSourceRepoIds(project, currentRepoIds) + return sourceRepoIds.length === project.sourceRepoIds.length + ? project + : { ...project, sourceRepoIds } +} + +function mergePreviousProjectMetadata( + previous: Project, + current: Project, + reposById: ReadonlyMap, + hostId: string +): Project { + const project = mergeProjectCompatibilityProject(previous, current) + if (hostId === LOCAL_EXECUTION_HOST_ID) { + // Why: `localWindowsRuntimePreference` belongs to the local host; a local + // refresh that omits it is authoritative and should clear stale renderer state. + if ('localWindowsRuntimePreference' in current) { + if (current.localWindowsRuntimePreference === undefined) { + delete project.localWindowsRuntimePreference + } else { + project.localWindowsRuntimePreference = current.localWindowsRuntimePreference + } + } else { + delete project.localWindowsRuntimePreference + } + } else if (previous.localWindowsRuntimePreference !== undefined) { + // Why: remote runtimes can have their own local Windows preference; they must + // not overwrite the client-local project runtime setting. + project.localWindowsRuntimePreference = previous.localWindowsRuntimePreference + } + return { + ...project, + // Why: fetched project metadata can lag behind repo.list; repo ownership + // must track the freshly reconciled repos so removed host repos do not linger. + sourceRepoIds: getMergedSourceRepoIdsForHostRefresh(previous, current, reposById, hostId) + } +} + function mergeProjectHostSetupCompatibility( derived: Pick, fetched: ProjectHostSetupProjection @@ -402,11 +560,11 @@ function mergeProjectHostSetupCompatibility( const derivedSetups = derived.projectHostSetups.filter( (setup) => !fetchedSetupOwners.has(getProjectHostSetupOwnerKey(setup)) ) - const projectHostSetups = mergeById(derivedSetups, fetched.setups) + const projectHostSetups = mergeProjectHostSetupsByOwner(derivedSetups, fetched.setups) const setupProjectIds = new Set(projectHostSetups.map((setup) => setup.projectId)) const fetchedProjectIds = new Set(fetched.projects.map((project) => project.id)) return { - projects: mergeById(derived.projects, fetched.projects).filter( + projects: mergeProjectCompatibilityProjects(derived.projects, fetched.projects).filter( (project) => fetchedProjectIds.has(project.id) || setupProjectIds.has(project.id) ), projectHostSetups @@ -414,30 +572,58 @@ function mergeProjectHostSetupCompatibility( } function getProjectHostSetupOwnerKey(setup: ProjectHostSetup): string { - return `${setup.hostId}:${setup.repoId ?? setup.id}` + return `${setup.hostId}:${setup.repoId || setup.id}` +} + +function mergeProjectHostSetupsByOwner( + base: readonly ProjectHostSetup[], + overlay: readonly ProjectHostSetup[] +): ProjectHostSetup[] { + const merged = [...base] + const indexByOwner = new Map( + merged.map((entry, index) => [getProjectHostSetupOwnerKey(entry), index]) + ) + for (const entry of overlay) { + const index = indexByOwner.get(getProjectHostSetupOwnerKey(entry)) + if (index === undefined) { + indexByOwner.set(getProjectHostSetupOwnerKey(entry), merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged } function getProjectHostIds( project: Project, setups: readonly ProjectHostSetup[], repos: readonly Repo[] +): Set { + const hostIds = getExplicitProjectHostIds(project, setups, repos) + if (hostIds.size === 0) { + hostIds.add(LOCAL_EXECUTION_HOST_ID) + } + return hostIds +} + +function getExplicitProjectHostIds( + project: Project, + setups: readonly ProjectHostSetup[], + repos: readonly Repo[] ): Set { const hostIds = new Set() - const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const sourceRepoIds = new Set(project.sourceRepoIds) for (const setup of setups) { if (setup.projectId === project.id) { hostIds.add(setup.hostId) } } - for (const repoId of project.sourceRepoIds) { - const repo = repoById.get(repoId) - if (repo) { + for (const repo of repos) { + if (sourceRepoIds.has(repo.id)) { hostIds.add(getRepoExecutionHostId(repo)) } } - if (hostIds.size === 0) { - hostIds.add(LOCAL_EXECUTION_HOST_ID) - } return hostIds } @@ -454,15 +640,49 @@ function mergeFetchedProjectCompatibilityForHost({ }): Pick { const fetchedSetupsForHost = fetched.projectHostSetups.filter((setup) => setup.hostId === hostId) const preservedSetups = previous.projectHostSetups.filter((setup) => setup.hostId !== hostId) - const projectHostSetups = mergeById(preservedSetups, fetchedSetupsForHost) - const fetchedProjectsForHost = fetched.projects.filter((project) => - getProjectHostIds(project, fetched.projectHostSetups, repos).has(hostId) - ) + const projectHostSetups = mergeProjectHostSetupsByOwner(preservedSetups, fetchedSetupsForHost) + const previousProjectById = new Map(previous.projects.map((project) => [project.id, project])) + const reposById = getReposById(repos) + const currentRepoIds = new Set(repos.map((repo) => repo.id)) + const projectHasHost = (project: Project, setups: readonly ProjectHostSetup[]): boolean => + getProjectHostIds(project, setups, repos).has(hostId) + const projectHasCurrentOwnerOutsideHost = (project: Project): boolean => + [...getExplicitProjectHostIds(project, projectHostSetups, repos)].some( + (ownerHostId) => ownerHostId !== hostId + ) + const fetchedProjects = fetched.projects + .filter((project) => { + const previousProject = previousProjectById.get(project.id) + // Why: repo-derived compatibility projects include every known host. + // A one-host refresh should only reconcile that host or prune its stale ownership. + return ( + projectHasHost(project, fetched.projectHostSetups) || + (previousProject ? projectHasHost(previousProject, previous.projectHostSetups) : false) + ) + }) + .map((project) => { + const previousProject = previousProjectById.get(project.id) + return previousProject + ? mergePreviousProjectMetadata(previousProject, project, reposById, hostId) + : projectWithCurrentSourceRepoIds(project, currentRepoIds) + }) + const fetchedProjectIds = new Set(fetchedProjects.map((project) => project.id)) const preservedProjects = previous.projects.filter( - (project) => !getProjectHostIds(project, previous.projectHostSetups, repos).has(hostId) + (project) => + !fetchedProjectIds.has(project.id) && + (!getProjectHostIds(project, previous.projectHostSetups, repos).has(hostId) || + projectHasCurrentOwnerOutsideHost(project)) ) return { - projects: mergeById(preservedProjects, fetchedProjectsForHost), + projects: mergeProjectCompatibilityProjects( + preservedProjects.map((project) => { + const sourceRepoIds = getSourceRepoIdsOutsideHost(project, reposById, hostId) + return sourceRepoIds.length === project.sourceRepoIds.length + ? project + : { ...project, sourceRepoIds } + }), + fetchedProjects + ), projectHostSetups } } @@ -487,25 +707,41 @@ function mergeFetchedReposForHost( fetched: Repo[], hostId: string ): Repo[] { - const fetchedIds = new Set(fetched.map((repo) => repo.id)) + const fetchedIdentities = new Set(fetched.map(getRepoHostIdentity)) const preserved = previous.filter((repo) => { const existingHostId = getRepoExecutionHostId(repo) - return existingHostId !== hostId || fetchedIds.has(repo.id) + return existingHostId !== hostId || fetchedIdentities.has(getRepoHostIdentity(repo)) }) - const preservedById = new Map(preserved.map((repo) => [repo.id, repo])) const merged = [...preserved] + const indexByIdentity = new Map(merged.map((repo, index) => [getRepoHostIdentity(repo), index])) for (const repo of fetched) { - const existingIndex = merged.findIndex((entry) => entry.id === repo.id) - if (existingIndex === -1) { + const identity = getRepoHostIdentity(repo) + const existingIndex = indexByIdentity.get(identity) + if (existingIndex === undefined) { + indexByIdentity.set(identity, merged.length) merged.push(repo) continue } merged[existingIndex] = repo } - return reconcileFetchedRepos( + return reconcileFetchedRepos(previous, merged) +} + +function mergeProjectCompatibilityForHostRepoChange({ + previous, + nextRepos, + hostId +}: { + previous: Pick + nextRepos: readonly Repo[] + hostId: string +}): Pick { + return mergeFetchedProjectCompatibilityForHost({ previous, - merged.filter((repo) => preservedById.has(repo.id) || fetchedIds.has(repo.id)) - ) + fetched: projectCompatibilityFromRepos(nextRepos), + repos: nextRepos, + hostId + }) } function getProjectGroupHostId(group: Pick) { @@ -645,7 +881,26 @@ async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[] } function settingsForRepoOwner(state: Pick, repoId: string) { - return getSettingsForRepoRuntimeOwner(state, repoId) as AppState['settings'] + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) + 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' || parsed?.kind === 'ssh') && + state.settings?.activeRuntimeEnvironmentId + ) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + return state.settings } function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string { @@ -1536,6 +1791,9 @@ export const createRepoSlice: StateCreator = (set, moveProjectToGroup: async (projectId, groupId, order) => { try { + if (!findRepoForHost(get().repos, projectId, { settings: get().settings })) { + return false + } const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const moved = target.kind === 'local' @@ -1556,10 +1814,21 @@ export const createRepoSlice: StateCreator = (set, return false } const ownedMoved = repoWithFetchedOwner(moved, target) - set((s) => ({ - repos: s.repos.map((repo) => (repo.id === projectId ? ownedMoved : repo)), - folderWorkspacePathStatuses: {} - })) + const movedHostId = getRepoExecutionHostId(ownedMoved) + set((s) => { + const nextRepos = s.repos.map((repo) => + repoMatchesHostIdentity(repo, projectId, movedHostId) ? ownedMoved : repo + ) + return { + repos: nextRepos, + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: movedHostId + }), + folderWorkspacePathStatuses: {} + } + }) return true } catch (err) { console.error('Failed to move repo to group:', err) @@ -1602,18 +1871,24 @@ export const createRepoSlice: StateCreator = (set, return null } repo = repoWithFetchedOwner(repo, target) - const alreadyAdded = get().repos.some((r) => r.id === repo.id) + const repoIdentity = getRepoHostIdentity(repo) + const alreadyAdded = get().repos.some((r) => getRepoHostIdentity(r) === repoIdentity) if (alreadyAdded) { get().clearOrcaHookTrustForRepo(repo.id) } set((s) => { - if (s.repos.some((r) => r.id === repo.id)) { + if (s.repos.some((r) => getRepoHostIdentity(r) === repoIdentity)) { return s } const nextRepos = [...s.repos, repo] + const hostId = getRepoExecutionHostId(repo) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId + }), folderWorkspacePathStatuses: {} } }) @@ -1660,10 +1935,15 @@ export const createRepoSlice: StateCreator = (set, ) ).result const repo = repoWithFetchedOwner(result.repo, target) + const repoHostId = getRepoExecutionHostId(repo) 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)) + const nextRepos = s.repos.some((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) + ) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) ? 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)) @@ -1748,10 +2028,13 @@ export const createRepoSlice: StateCreator = (set, ).result const setup = setupWithFetchedOwner(result.setup, target) const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => ({ repos: repo - ? s.repos.some((entry) => entry.id === repo.id) - ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + ? s.repos.some((entry) => repoMatchesHostIdentity(entry, repo.id, repoHostId!)) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId!) ? repo : entry + ) : [...s.repos, repo] : s.repos, projects: s.projects.some((entry) => entry.id === result.project.id) @@ -1792,11 +2075,15 @@ export const createRepoSlice: StateCreator = (set, ) ).result const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null 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 repos = + repo && repoHostId + ? s.repos.filter((entry) => !repoMatchesHostIdentity(entry, repo.id, repoHostId)) + : s.repos const projects = repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) ? s.projects.filter((project) => project.id !== result.project.id) @@ -1930,19 +2217,26 @@ export const createRepoSlice: StateCreator = (set, removeProject: async (projectId) => { try { + const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings }) + if (!ownerRepo) { + return + } + const ownerHostId = getRepoExecutionHostId(ownerRepo) 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 })) get().clearOrcaHookTrustForRepo(projectId) - const repoPath = get().repos.find((repo) => repo.id === projectId)?.path + const repoPath = get().repos.find((repo) => + repoMatchesHostIdentity(repo, projectId, ownerHostId) + )?.path get().evictGitHubRepoCaches(projectId, repoPath) const { clearRepoSlugCacheEntry } = await import('../../lib/repo-slug-index') clearRepoSlugCacheEntry(projectId) // Kill PTYs for all worktrees belonging to this repo - const worktreeIds = getKnownRepoWorktreeIds(get(), projectId) + const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) const killedTabIds = new Set() const killedPtyIds = new Set() if (target.kind === 'environment') { @@ -1979,9 +2273,26 @@ export const createRepoSlice: StateCreator = (set, set((s) => { const nextWorktrees = { ...s.worktreesByRepo } - delete nextWorktrees[projectId] + const remainingWorktrees = (nextWorktrees[projectId] ?? []).filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingWorktrees.length > 0) { + nextWorktrees[projectId] = remainingWorktrees + } else { + delete nextWorktrees[projectId] + } const nextDetectedWorktrees = { ...s.detectedWorktreesByRepo } - delete nextDetectedWorktrees[projectId] + const detected = nextDetectedWorktrees[projectId] + if (detected) { + const remainingDetected = detected.worktrees.filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingDetected.length > 0) { + nextDetectedWorktrees[projectId] = { ...detected, worktrees: remainingDetected } + } else { + delete nextDetectedWorktrees[projectId] + } + } const nextTabs = { ...s.tabsByWorktree } const nextLayouts = { ...s.terminalLayoutsByTabId } const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } @@ -2019,17 +2330,21 @@ export const createRepoSlice: StateCreator = (set, // forever after the repo is removed. let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) { - if (getRepoIdFromWorktreeId(id) === projectId) { + if (worktreeIdSet.has(id)) { if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) { nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId } } delete nextLastVisitedAtByWorktreeId[id] } } - const nextRepos = s.repos.filter((r) => r.id !== projectId) + const nextRepos = s.repos.filter((r) => !repoMatchesHostIdentity(r, projectId, ownerHostId)) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId, filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId), worktreesByRepo: nextWorktrees, @@ -2087,7 +2402,9 @@ export const createRepoSlice: StateCreator = (set, const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates set((state) => ({ projects: state.projects.map((project) => - project.id === projectId ? updatedProject : project + project.id === projectId + ? mergeUpdatedProjectCompatibilityProject(project, updatedProject, updates) + : project ), folderWorkspacePathStatuses: {} })) @@ -2104,10 +2421,25 @@ export const createRepoSlice: StateCreator = (set, updateRepo: async (projectId, updates) => { const updateRepoChains = getRepoUpdateChains(get) + const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings }) + if (!ownerRepo) { + return false + } + const ownerHasExplicitHost = Boolean( + ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim() + ) + const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo) + const ownerTarget = ownerHasExplicitHost + ? getProjectSetupRuntimeTarget(explicitOwnerHostId) + : getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const ownerHostId = ownerHasExplicitHost + ? explicitOwnerHostId + : getRuntimeTargetHostId(ownerTarget) + const updateChainKey = getRepoHostIdentityForParts(projectId, ownerHostId) const applyRepoUpdate = async () => { try { const sanitizedUpdates = sanitizeRepoUpdate(updates) - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const target = ownerTarget const updatedRepo = target.kind === 'local' ? await window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates }) @@ -2121,7 +2453,10 @@ export const createRepoSlice: StateCreator = (set, ).repo set((s) => { const nextRepos = s.repos.map((r) => { - if (r.id !== projectId) { + const matchesOwner = ownerHasExplicitHost + ? repoMatchesHostIdentity(r, projectId, ownerHostId) + : repoMatchesHostIdentity(r, projectId, ownerHostId) || r === ownerRepo + if (!matchesOwner) { return r } if (updatedRepo) { @@ -2142,7 +2477,11 @@ export const createRepoSlice: StateCreator = (set, }) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), folderWorkspacePathStatuses: {} } }) @@ -2152,16 +2491,16 @@ export const createRepoSlice: StateCreator = (set, return false } } - const previous = updateRepoChains.get(projectId) + const previous = updateRepoChains.get(updateChainKey) // Why: repo settings are persisted as full nested values. Preserve call // order per repo so a slower IPC/RPC response cannot overwrite newer state. const next = previous ? previous.catch(() => undefined).then(applyRepoUpdate) : applyRepoUpdate() - updateRepoChains.set(projectId, next) + updateRepoChains.set(updateChainKey, next) const cleanup = () => { - if (updateRepoChains.get(projectId) === next) { - updateRepoChains.delete(projectId) + if (updateRepoChains.get(updateChainKey) === next) { + updateRepoChains.delete(updateChainKey) } } void next.then(cleanup, cleanup) @@ -2174,10 +2513,22 @@ export const createRepoSlice: StateCreator = (set, // Optimistically apply the new order so the sidebar updates instantly; // resync only if main rejects (stale permutation due to a racing add/remove). const previous = get().repos - const byId = new Map(previous.map((r) => [r.id, r])) + const remainingById = new Map() + for (const repo of previous) { + const existing = remainingById.get(repo.id) + if (existing) { + existing.repos.push(repo) + } else { + remainingById.set(repo.id, { repos: [repo], nextIndex: 0 }) + } + } const next: Repo[] = [] for (const id of orderedIds) { - const repo = byId.get(id) + const remaining = remainingById.get(id) + const repo = remaining?.repos[remaining.nextIndex] + if (remaining) { + remaining.nextIndex += 1 + } if (repo) { next.push(repo) } @@ -2188,7 +2539,6 @@ export const createRepoSlice: StateCreator = (set, } set({ repos: next, - ...projectCompatibilityFromRepos(next), folderWorkspacePathStatuses: {} }) try { @@ -2214,11 +2564,11 @@ export const createRepoSlice: StateCreator = (set, }) ) if (results.some((result) => result.status === 'rejected')) { - await get().fetchRepos() + await get().fetchReposForAllHosts() } } catch (err) { console.error('Failed to reorder repos:', err) - await get().fetchRepos() + await get().fetchReposForAllHosts() } } }) diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 032fa0d81e5..4f2d06848e0 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -80,6 +80,10 @@ import { } from './store-test-helpers' import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers' +import { + loadSessionCommitDrafts, + saveSessionCommitDrafts +} from '@/lib/source-control-commit-draft-session' // ─── Tests ──────────────────────────────────────────────────────────── @@ -87,6 +91,7 @@ describe('removeWorktree cascade', () => { beforeEach(() => { vi.clearAllMocks() clearRuntimeCompatibilityCacheForTests() + saveSessionCommitDrafts({}) mockApi.worktrees.remove.mockResolvedValue(undefined) mockApi.worktrees.forceDeletePreservedBranch.mockResolvedValue({ deleted: true }) mockApi.runtimeEnvironments.call.mockReset() @@ -149,6 +154,10 @@ describe('removeWorktree cascade', () => { activeTabTypeByWorktree: { [worktreeId]: 'editor' }, rightSidebarExplorerViewByWorktree: { [worktreeId]: 'search' } }) + saveSessionCommitDrafts({ + [worktreeId]: 'feat: stale draft', + 'repo1::/path/wt2': 'fix: keep draft' + }) const result = await store.getState().removeWorktree(worktreeId) const s = store.getState() @@ -170,6 +179,7 @@ describe('removeWorktree cascade', () => { expect(s.activeFileIdByWorktree[worktreeId]).toBeUndefined() expect(s.activeTabTypeByWorktree[worktreeId]).toBeUndefined() expect(s.rightSidebarExplorerViewByWorktree[worktreeId]).toBeUndefined() + expect(loadSessionCommitDrafts()).toEqual({ 'repo1::/path/wt2': 'fix: keep draft' }) }) it('warns when workspace removal keeps the local branch', async () => { diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index da3524afdcf..d2f93e73ab4 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -101,6 +101,8 @@ const mockApi = { globalThis.window = { api: mockApi } import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test-helpers' +import { computeVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' // ─── Helpers ────────────────────────────────────────────────────────── @@ -1490,13 +1492,13 @@ describe('reconnectPersistedTerminals', () => { [wt2]: [makeTab({ id: 'tab2', worktreeId: wt2, ptyId: 'old-pty-2' })] }, terminalLayoutsByTabId: { tab1: makeLayout(), tab2: makeLayout() }, - activeWorktreeIdsOnShutdown: [wt1, wt2] + activeWorktreeIdsOnShutdown: [wt1] }) expect(store.getState().workspaceSessionReady).toBe(false) expect(store.getState().tabsByWorktree[wt1][0].ptyId).toBeNull() expect(store.getState().tabsByWorktree[wt2][0].ptyId).toBeNull() - expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1, wt2]) + expect(store.getState().pendingReconnectWorktreeIds).toEqual([wt1]) await store.getState().reconnectPersistedTerminals() @@ -1506,11 +1508,24 @@ describe('reconnectPersistedTerminals', () => { // records daemon session IDs as tab-level ptyIds so connectPanePty // can pass them as sessionId to the daemon's createOrAttach. expect(s.tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1') - expect(s.tabsByWorktree[wt2][0].ptyId).toBe('old-pty-2') + expect(s.tabsByWorktree[wt2][0].ptyId).toBeNull() expect(s.ptyIdsByTabId.tab1).toEqual(['old-pty-1']) - // Why: inactive worktrees keep a wake hint but must not advertise live PTYs - // until the user opens them and connectPanePty performs the actual reattach. expect(s.ptyIdsByTabId.tab2).toEqual([]) + expect( + computeVisibleWorktreeIds(s.worktreesByRepo, [wt1, wt2], { + filterRepoIds: [], + showSleepingWorkspaces: false, + tabsByWorktree: s.tabsByWorktree, + ptyIdsByTabId: s.ptyIdsByTabId, + browserTabsByWorktree: s.browserTabsByWorktree, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {} + }) + ).toEqual([wt1]) expect(s.pendingReconnectWorktreeIds).toEqual([]) // No eager spawn — PTY creation deferred to pane mount expect((mockApi.pty as Record).spawn).not.toHaveBeenCalled() @@ -1801,6 +1816,65 @@ describe('reconnectPersistedTerminals', () => { expect(Object.values(bindings).sort()).toEqual(['daemon-session-A', 'daemon-session-B']) expect(s.workspaceSessionReady).toBe(true) }) + + it('does not advertise split-pane-only wake hints as hide-sleeping activity', async () => { + const store = createDaemonEnabledStore() + const wt1 = 'repo1::/path/wt1' + + store.setState({ + repos: [ + { id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + repo1: [makeWorktree({ id: wt1, repoId: 'repo1', path: '/path/wt1' })] + } + }) + + store.getState().hydrateWorkspaceSession({ + activeRepoId: 'repo1', + activeWorktreeId: wt1, + activeTabId: 'tab1', + tabsByWorktree: { + [wt1]: [makeTab({ id: 'tab1', worktreeId: wt1, ptyId: null })] + }, + terminalLayoutsByTabId: { + tab1: { + ...makeLayout(), + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'pane:1' }, + second: { type: 'leaf', leafId: 'pane:3' } + }, + ptyIdsByLeafId: { 'pane:1': 'daemon-session-A', 'pane:3': 'daemon-session-B' } + } + }, + activeWorktreeIdsOnShutdown: [] + }) + + expect(store.getState().pendingReconnectWorktreeIds).toEqual([]) + + await store.getState().reconnectPersistedTerminals() + + const s = store.getState() + expect(s.terminalLayoutsByTabId.tab1?.ptyIdsByLeafId).toBeDefined() + expect(s.ptyIdsByTabId.tab1).toEqual([]) + expect( + computeVisibleWorktreeIds(s.worktreesByRepo, [wt1], { + filterRepoIds: [], + showSleepingWorkspaces: false, + tabsByWorktree: s.tabsByWorktree, + ptyIdsByTabId: s.ptyIdsByTabId, + browserTabsByWorktree: s.browserTabsByWorktree, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), + workspaceHostScope: 'all', + defaultHostId: LOCAL_EXECUTION_HOST_ID, + worktreeLineageById: {} + }) + ).toEqual([]) + }) }) describe('hydrateEditorSession', () => { diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 358d9b9598c..4bbcc75cd2d 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -851,6 +851,48 @@ describe('TabsSlice', () => { // on that group's active terminal tab. Without this, the bell lingers // until the tab is clicked a second time. describe('focusGroup', () => { + it('does not broadcast active-surface writes when the focused group is already current', () => { + const editorFileId = '/tmp/feature/src/main.ts' + const tab = store.getState().createUnifiedTab(WT, 'editor', { + id: 'editor-tab-1', + entityId: editorFileId, + label: 'main.ts' + }) + const groupId = store.getState().groupsByWorktree[WT][0].id + store.setState({ + activeWorktreeId: WT, + openFiles: [makeOpenFile({ id: editorFileId, worktreeId: WT })], + activeGroupIdByWorktree: { [WT]: groupId }, + activeFileId: editorFileId, + activeFileIdByWorktree: { [WT]: editorFileId }, + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: { [WT]: null }, + activeTabId: null, + activeTabIdByWorktree: { [WT]: null }, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' }, + groupsByWorktree: { + [WT]: [ + { + ...store.getState().groupsByWorktree[WT][0], + activeTabId: tab.id + } + ] + } + }) + const before = store.getState() + const listener = vi.fn() + const unsubscribe = store.subscribe(listener) + + store.getState().focusGroup(WT, groupId) + unsubscribe() + + expect(listener).not.toHaveBeenCalled() + expect(store.getState().activeGroupIdByWorktree).toBe(before.activeGroupIdByWorktree) + expect(store.getState().activeFileIdByWorktree).toBe(before.activeFileIdByWorktree) + expect(store.getState().activeTabTypeByWorktree).toBe(before.activeTabTypeByWorktree) + }) + // Why: focusGroup is fired on every pointerdown within a split group's // chrome (onPointerDown + onFocusCapture in TabGroupPanel). Clearing the // tab-level bell here is fine — the user is now looking at this group. diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 04e83253973..fd54c54cde7 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -534,6 +534,44 @@ function buildActiveSurfacePatch( } } +function activeSurfacePatchMatchesState( + state: Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + >, + worktreeId: string, + patch: Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + > +): boolean { + return ( + state.activeBrowserTabId === patch.activeBrowserTabId && + state.activeBrowserTabIdByWorktree[worktreeId] === + patch.activeBrowserTabIdByWorktree[worktreeId] && + state.activeFileId === patch.activeFileId && + state.activeFileIdByWorktree[worktreeId] === patch.activeFileIdByWorktree[worktreeId] && + state.activeTabId === patch.activeTabId && + state.activeTabIdByWorktree[worktreeId] === patch.activeTabIdByWorktree[worktreeId] && + state.activeTabType === patch.activeTabType && + state.activeTabTypeByWorktree[worktreeId] === patch.activeTabTypeByWorktree[worktreeId] + ) +} + export const createTabsSlice: StateCreator = (set, get) => ({ unifiedTabsByWorktree: {}, renamingTabId: null, @@ -1171,10 +1209,13 @@ export const createTabsSlice: StateCreator = (set, focusGroup: (worktreeId, groupId) => set((state) => { - const nextActiveGroupIdByWorktree = { - ...state.activeGroupIdByWorktree, - [worktreeId]: groupId - } + const groupAlreadyFocused = state.activeGroupIdByWorktree[worktreeId] === groupId + const nextActiveGroupIdByWorktree = groupAlreadyFocused + ? state.activeGroupIdByWorktree + : { + ...state.activeGroupIdByWorktree, + [worktreeId]: groupId + } // Why: focusing a split group surfaces whichever terminal tab is already // active in that group, so the tab-level bell is no longer needed. // @@ -1185,6 +1226,9 @@ export const createTabsSlice: StateCreator = (set, // before the user ever sees the tab. All current callers only fire for // the active worktree, but this guard prevents future misuse. if (state.activeWorktreeId !== worktreeId) { + if (groupAlreadyFocused) { + return state + } return { activeGroupIdByWorktree: nextActiveGroupIdByWorktree } @@ -1214,8 +1258,23 @@ export const createTabsSlice: StateCreator = (set, return changed ? copy : state.unreadTerminalTabs })() : state.unreadTerminalTabs + const activeSurfacePatch = buildActiveSurfacePatch( + { + ...state, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + groupId + ) + if ( + groupAlreadyFocused && + nextUnreadTerminalTabs === state.unreadTerminalTabs && + activeSurfacePatchMatchesState(state, worktreeId, activeSurfacePatch) + ) { + return state + } return { - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + ...(groupAlreadyFocused ? {} : { activeGroupIdByWorktree: nextActiveGroupIdByWorktree }), // Why: only write unreadTerminalTabs back into state when it actually // changed. The IIFE above returns state.unreadTerminalTabs by reference // on no-op; preserving that reference via conditional spread keeps @@ -1224,14 +1283,7 @@ export const createTabsSlice: StateCreator = (set, ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs ? { unreadTerminalTabs: nextUnreadTerminalTabs } : {}), - ...buildActiveSurfacePatch( - { - ...state, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - groupId - ) + ...activeSurfacePatch } }), diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index e235f65673f..b99b248a503 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -2595,10 +2595,9 @@ export const createTerminalSlice: StateCreator // reconnectPersistedTerminals() after all eager PTY spawns complete. // This prevents TerminalPane from mounting and spawning duplicate PTYs // before the reconnect phase has set ptyId on each tab. - // Why: fall back to deriving the list from tabsByWorktree ptyIds when - // activeWorktreeIdsOnShutdown is absent (upgrade from older build). - // The raw tabs still carry ptyId values before clearTransientTerminalState - // nulls them, so we can infer which worktrees had active terminals. + // Why: match the pre-idle-runtime-optimization startup contract. + // activeWorktreeIdsOnShutdown is authoritative when present; persisted + // tab/layout PTY IDs are wake hints, not a broader active-workspace list. const shutdownIds = session.activeWorktreeIdsOnShutdown ?? Object.entries(session.tabsByWorktree) @@ -2794,8 +2793,7 @@ export const createTerminalSlice: StateCreator pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, terminalLayoutsByTabId, - tabsByWorktree, - activeWorktreeId + tabsByWorktree } = get() const ids = pendingReconnectWorktreeIds ?? [] @@ -2869,15 +2867,11 @@ export const createTerminalSlice: StateCreator `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` ) if (tabLevelPtyId) { - const shouldAdvertiseLivePtys = worktreeId === activeWorktreeId set((s) => { const next = { ...s.tabsByWorktree } if (!next[worktreeId]) { return {} } - next[worktreeId] = next[worktreeId].map((t) => - t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t - ) // Why: populate ptyIdsByTabId so the sessions status segment // can map daemon session IDs back to tabs (for bound/orphan @@ -2885,20 +2879,19 @@ export const createTerminalSlice: StateCreator // appear as orphans until the terminal pane mounts. const allPtyIds = hasLeafMappings ? (Object.values(leafPtyMap).filter(Boolean) as string[]) - : [tabLevelPtyId] + : [tabLevelPtyId!] + next[worktreeId] = next[worktreeId].map((t) => + t.id === tabId ? { ...t, ptyId: tabLevelPtyId } : t + ) return { tabsByWorktree: next, - ...(shouldAdvertiseLivePtys - ? { - // Why: inactive worktrees only need wake hints. Publishing - // their PTYs as live at startup starts global session/status - // machinery before the user opens that workspace. - ptyIdsByTabId: { - ...s.ptyIdsByTabId, - [tabId]: allPtyIds - } - } - : {}) + // Why: hide-sleeping uses ptyIdsByTabId as the liveness source. + // Restored daemon sessions are still running even before their + // pane remounts, so background workspaces must advertise them. + ptyIdsByTabId: { + ...s.ptyIdsByTabId, + [tabId]: allPtyIds + } } }) } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index ceb59dd752a..5e0c78c2945 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -117,6 +117,12 @@ export type PendingSidebarWorktreeReveal = { beginRename?: boolean } +export type PendingSidebarRowReveal = { + rowKey: string + behavior: 'auto' | 'smooth' + highlight?: boolean +} + export type AgentSendPopoverTargetMode = { id: string instanceId: string @@ -847,6 +853,7 @@ export type UISlice = { petSize: number setPetSize: (size: number) => void pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null revealWorktreeInSidebar: ( worktreeId: string, options?: { @@ -855,7 +862,15 @@ export type UISlice = { beginRename?: boolean } ) => void + revealSidebarRow: ( + rowKey: string, + options?: { + behavior?: PendingSidebarRowReveal['behavior'] + highlight?: boolean + } + ) => void clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void // Why: lets the SourceControl sidebar request that the diff editor scroll // to a specific note. Cleared by the diff decorator after it reveals the // line, so the same id can be requested again later without the surface @@ -2145,6 +2160,7 @@ export const createUISlice: StateCreator = (set, get) }), pendingRevealWorktree: null, + pendingRevealSidebarRow: null, revealWorktreeInSidebar: (worktreeId, options) => set({ pendingRevealWorktree: { @@ -2154,7 +2170,16 @@ export const createUISlice: StateCreator = (set, get) ...(options?.beginRename ? { beginRename: true } : {}) } }), + revealSidebarRow: (rowKey, options) => + set({ + pendingRevealSidebarRow: { + rowKey, + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight === false ? {} : { highlight: true }) + } + }), clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), + clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), scrollToDiffCommentId: null, setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }), persistedUIReady: false, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index c76cc38406a..00e8c7903b2 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4285,6 +4285,89 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) + it('preserves sibling host worktrees during hydrated refresh when repo ids are duplicated', async () => { + const store = createTestStore() + const localRepo = { + id: 'same-repo', + path: '/repos/local', + displayName: 'same local', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'local' + } + const runtimeRepo = { + id: 'same-repo', + path: '/repos/remote', + displayName: 'same remote', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo', + path: '/local/wt' + }) + const staleRemoteWorktree = makeWorktree({ + id: 'same-repo::/remote/stale', + repoId: 'same-repo', + path: '/remote/stale', + hostId: 'runtime:env-1' + }) + const refreshedRemoteWorktree = makeWorktree({ + id: 'same-repo::/remote/fresh', + repoId: 'same-repo', + path: '/remote/fresh', + hostId: 'local' + }) + + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('same-repo', [localWorktree]) + ) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-worktrees', + ok: true, + result: makeDetectedResult('same-repo', [refreshedRemoteWorktree]), + _meta: { runtimeId: 'runtime-remote' } + }) + + store.setState({ + hasHydratedWorktreePurge: true, + repos: [localRepo, runtimeRepo], + worktreesByRepo: { + 'same-repo': [localWorktree, staleRemoteWorktree] + }, + detectedWorktreesByRepo: { + 'same-repo': makeDetectedResult('same-repo', [localWorktree, staleRemoteWorktree]) + } + } as unknown as Partial) + + await store.getState().fetchAllWorktrees() + + const refreshed = store.getState().worktreesByRepo['same-repo'] ?? [] + expect(refreshed).toHaveLength(2) + expect(refreshed).toEqual( + expect.arrayContaining([ + localWorktree, + { ...refreshedRemoteWorktree, hostId: 'runtime:env-1' } + ]) + ) + expect(refreshed.map((worktree) => worktree.id)).not.toContain(staleRemoteWorktree.id) + expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: localWorktree.id }), + expect.objectContaining({ id: refreshedRemoteWorktree.id, hostId: 'runtime:env-1' }) + ]) + ) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.detectedList', + params: { repo: 'same-repo' }, + timeoutMs: 15_000 + }) + }) + it('bounds concurrent repo scans during hydration-time refresh', async () => { const store = createTestStore() const repos = Array.from({ length: 7 }, (_, index) => ({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index bd353c9fc1b..2c9b3ef5e67 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -24,6 +24,7 @@ import { getRepoIdFromWorktreeId, type WorktreeSlice } from './worktree-helpers' +import { findRepoForHost } from './repo-host-identity' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { @@ -39,6 +40,7 @@ import { toast } from 'sonner' import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' import { branchName } from '@/lib/git-utils' import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' +import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' import { translate } from '@/i18n/i18n' @@ -71,11 +73,11 @@ const detachedHeadAutoDerivedDisplayNames = new Map() const folderWorkspaceWorktreeCache = new WeakMap() const hostedReviewPushTargetLookupsInFlight = new Set() -async function mapReposForWorktreeRefresh( - repos: readonly { id: string }[], - mapper: (repo: { id: string }) => Promise -): Promise { - const results = Array(repos.length) +async function mapReposForWorktreeRefresh( + repos: readonly TRepo[], + mapper: (repo: TRepo) => Promise +): Promise { + const results = Array(repos.length) let nextIndex = 0 const workerCount = Math.min(WORKTREE_REFRESH_CONCURRENCY, repos.length) @@ -325,8 +327,12 @@ function withRepoHostId( return hostId === LOCAL_EXECUTION_HOST_ID ? worktree : { ...worktree, hostId } } -function repoHostId(state: Pick, repoId: string): ExecutionHostId { - const repo = state.repos.find((entry) => entry.id === repoId) +function repoHostId( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null +): ExecutionHostId { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID } @@ -344,19 +350,133 @@ function getHydratedSessionWorktreeIdsForRepo(state: AppState, repoId: string): return Object.keys(state.tabsByWorktree).filter((id) => getRepoIdFromWorktreeId(id) === repoId) } -function getKnownWorktreeIdsForPurge(state: AppState, repoId: string): string[] { +type WorktreeHostMatchOptions = { + unhostedWorktreesMatchHost?: boolean +} + +type RepoHostSummary = { + count: number + onlyHostId?: ExecutionHostId +} + +const repoHostSummariesByRepos = new WeakMap>() + +function getRepoHostSummaries(repos: AppState['repos']): Map { + const cached = repoHostSummariesByRepos.get(repos) + if (cached) { + return cached + } + + const summaries = new Map() + for (const repo of repos) { + const current = summaries.get(repo.id) + if (current) { + summaries.set(repo.id, { count: current.count + 1 }) + } else { + summaries.set(repo.id, { count: 1, onlyHostId: getRepoExecutionHostId(repo) }) + } + } + repoHostSummariesByRepos.set(repos, summaries) + return summaries +} + +function unhostedWorktreesMatchRefreshHost( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): boolean { + if (hostId === LOCAL_EXECUTION_HOST_ID) { + return true + } + + const summary = getRepoHostSummaries(state.repos).get(repoId) + return summary?.count === 1 && summary.onlyHostId === hostId +} + +function worktreeHostMatchOptions( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): WorktreeHostMatchOptions { + return { + // Why: pre-host persisted runtime/SSH worktrees were stored without hostId. + // Treat them as the sole repo owner's rows, but keep ambiguous duplicates local. + unhostedWorktreesMatchHost: unhostedWorktreesMatchRefreshHost(state, repoId, hostId) + } +} + +function worktreeMatchesHost( + worktree: { hostId?: ExecutionHostId }, + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions = {} +): boolean { + if (worktree.hostId) { + return worktree.hostId === hostId + } + return options.unhostedWorktreesMatchHost ?? hostId === LOCAL_EXECUTION_HOST_ID +} + +function mergeWorktreesForHost( + current: readonly T[] | undefined, + refreshed: readonly T[], + hostId: ExecutionHostId, + options?: WorktreeHostMatchOptions +): T[] { + // Why: host-scoped refreshes should replace that host in place so alternating + // local/runtime refreshes do not churn sibling row order or sortEpoch. + const existing = current ?? [] + const next: T[] = [] + let inserted = false + + for (const worktree of existing) { + if (worktreeMatchesHost(worktree, hostId, options)) { + if (!inserted) { + next.push(...refreshed) + inserted = true + } + continue + } + next.push(worktree) + } + + return inserted ? next : [...next, ...refreshed] +} + +function mergeDetectedWorktreesForHost( + current: DetectedWorktreeListResult | undefined, + refreshed: DetectedWorktreeListResult, + hostId: ExecutionHostId, + options?: WorktreeHostMatchOptions +): DetectedWorktreeListResult { + const refreshedForHost = refreshed.worktrees.map((worktree) => withRepoHostId(worktree, hostId)) + return { + ...refreshed, + worktrees: mergeWorktreesForHost(current?.worktrees, refreshedForHost, hostId, options) + } +} + +function getKnownWorktreeIdsForPurge( + state: AppState, + repoId: string, + hostId: ExecutionHostId +): string[] { const detected = state.detectedWorktreesByRepo[repoId] const knownIds = new Set() + const matchOptions = worktreeHostMatchOptions(state, repoId, hostId) if (detected?.authoritative === true) { for (const worktree of detected.worktrees) { - knownIds.add(worktree.id) + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } } } else { for (const worktree of state.worktreesByRepo[repoId] ?? []) { - knownIds.add(worktree.id) + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } } } - if (!state.hasHydratedWorktreePurge) { + if (!state.hasHydratedWorktreePurge && matchOptions.unhostedWorktreesMatchHost === true) { // Why (#1158): hydration can preserve tab keys before worktree metadata exists; // the first authoritative scan still needs to reap deleted session-only keys. for (const id of getHydratedSessionWorktreeIdsForRepo(state, repoId)) { @@ -369,13 +489,14 @@ function getKnownWorktreeIdsForPurge(state: AppState, repoId: string): string[] function getRemovedWorktreeIdsAfterAuthoritativeScan( state: AppState, repoId: string, - detected: DetectedWorktreeListResult + detected: DetectedWorktreeListResult, + hostId: ExecutionHostId ): string[] { if (!detected.authoritative) { return [] } const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) - return getKnownWorktreeIdsForPurge(state, repoId).filter((id) => !detectedIds.has(id)) + return getKnownWorktreeIdsForPurge(state, repoId, hostId).filter((id) => !detectedIds.has(id)) } function toLegacyDetectedWorktreeResult( @@ -594,30 +715,41 @@ function replaceWorktreeInRepoLists( } } -function settingsForRepoOwner(state: Pick, repoId: string) { - const repo = state.repos.find((entry) => entry.id === repoId) +function settingsForRepoOwner( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null +) { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) if (!repo) { return state.settings } + return settingsForKnownRepoOwner(state.settings, repo) +} + +function settingsForKnownRepoOwner( + settings: AppState['settings'], + repo: { connectionId?: string | null; executionHostId?: ExecutionHostId | null } +) { if (!repo.executionHostId && !repo.connectionId) { - return state.settings + return settings } const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) if (parsed?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + return settings + ? { ...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 === 'local' && settings?.activeRuntimeEnvironmentId) { + return { ...settings, activeRuntimeEnvironmentId: null } } if (parsed?.kind !== 'ssh') { - return state.settings + return 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 } + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) } @@ -640,14 +772,21 @@ function settingsForExecutionHostOwner( } function settingsForWorktreeOwner( - state: Pick, + state: Pick, worktreeId: string ) { const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) if (worktree?.hostId) { return settingsForExecutionHostOwner(state.settings, worktree.hostId) } - return settingsForRepoOwner(state, getRepoIdFromWorktreeId(worktreeId)) + const repoId = getRepoIdFromWorktreeId(worktreeId) + const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + (entry) => entry.id === worktreeId + ) + if (detected?.hostId) { + return settingsForExecutionHostOwner(state.settings, detected.hostId) + } + return settingsForRepoOwner(state, repoId) } async function listDetectedWorktreesForRepo( @@ -859,7 +998,7 @@ async function refreshRemoteWorktreeLineageBestEffort( } function getWorktreeHostId( - state: Pick, + state: Pick, worktreeId: string ): ExecutionHostId | null { const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) @@ -867,12 +1006,21 @@ function getWorktreeHostId( return worktree.hostId } const repoId = getRepoIdFromWorktreeId(worktreeId) - const repo = state.repos.find((entry) => entry.id === repoId) + const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + (entry) => entry.id === worktreeId + ) + if (detected?.hostId) { + return detected.hostId + } + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) return repo ? getRepoExecutionHostId(repo) : null } function mergeLineageForHost( - state: Pick, + state: Pick< + AppState, + 'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'worktreeLineageById' + >, hostId: ExecutionHostId, lineage: Record ): Record { @@ -886,7 +1034,14 @@ function mergeLineageForHost( } function mergeWorkspaceLineageForHost( - state: Pick, + state: Pick< + AppState, + | 'repos' + | 'settings' + | 'worktreesByRepo' + | 'detectedWorktreesByRepo' + | 'workspaceLineageByChildKey' + >, hostId: ExecutionHostId, lineage: Record ): Record { @@ -1551,24 +1706,58 @@ export const createWorktreeSlice: StateCreator fetchWorktrees: async (repoId, options) => { try { - const settings = settingsForRepoOwner(get(), repoId) + const ownerState = get() + const hostId = repoHostId(ownerState, repoId) + const settings = settingsForRepoOwner(ownerState, repoId, hostId) const detected = await listDetectedWorktreesForRepo(settings, repoId) if (options?.requireAuthoritative && !detected.authoritative) { return false } - const worktrees = toVisibleWorktrees(detected, repoHostId(get(), repoId)) + const worktrees = toVisibleWorktrees(detected, hostId) const current = get().worktreesByRepo[repoId] - if (areWorktreesEqual(current, worktrees)) { + const currentMatchOptions = worktreeHostMatchOptions(get(), repoId, hostId) + const currentForHost = (current ?? []).filter((worktree) => + worktreeMatchesHost(worktree, hostId, currentMatchOptions) + ) + if (areWorktreesEqual(currentForHost, worktrees)) { set((s) => { - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected) + const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( + s, + repoId, + detected, + hostId + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + matchOptions + ) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[repoId], + worktrees, + hostId, + matchOptions + ) + const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[repoId], mergedWorktrees) if ( - areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], detected) && + !worktreesChanged && + areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) && removedIds.length === 0 ) { return s } return { - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected }, + worktreesByRepo: { + ...s.worktreesByRepo, + [repoId]: mergedWorktrees + }, + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergedDetected + }, + ...(worktreesChanged ? { sortEpoch: s.sortEpoch + 1 } : {}), ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) @@ -1583,9 +1772,17 @@ export const createWorktreeSlice: StateCreator // badge then shows raw worktree IDs instead of display names, and click- // to-navigate silently fails because findWorktreeById returns undefined. // Keep the stale-but-correct data until the next successful refresh. - if (!detected.authoritative && worktrees.length === 0 && current && current.length > 0) { + if (!detected.authoritative && worktrees.length === 0 && currentForHost.length > 0) { set((s) => ({ - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected } + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + worktreeHostMatchOptions(s, repoId, hostId) + ) + } })) return false } @@ -1594,14 +1791,27 @@ export const createWorktreeSlice: StateCreator // Why: hidden worktrees are not in worktreesByRepo. Purge decisions // must diff against the previous authoritative detected list so hiding // does not delete state, and deleting a hidden worktree still does. - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected) + const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected, hostId) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[repoId], + worktrees, + hostId, + matchOptions + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + matchOptions + ) return { // Why: active worktrees can change branches entirely from a terminal. // We refresh that live git identity into renderer state, but only bump // sortEpoch when git actually reports a different worktree payload. - worktreesByRepo: { ...s.worktreesByRepo, [repoId]: worktrees }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected }, + worktreesByRepo: { ...s.worktreesByRepo, [repoId]: mergedWorktrees }, + detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: mergedDetected }, sortEpoch: s.sortEpoch + 1, ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } @@ -1621,7 +1831,41 @@ export const createWorktreeSlice: StateCreator // calls just need to refresh each repo's cached list. No need to // double-probe the IPC for the per-repo success signal. if (get().hasHydratedWorktreePurge) { - await mapReposForWorktreeRefresh(repos, (r) => get().fetchWorktrees(r.id)) + await mapReposForWorktreeRefresh(repos, async (r) => { + const hostId = getRepoExecutionHostId(r) + const settings = settingsForKnownRepoOwner(get().settings, r) + const detected = await listDetectedWorktreesForRepo(settings, r.id) + const worktrees = toVisibleWorktrees(detected, hostId) + set((s) => { + const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, r.id, detected, hostId) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[r.id], + worktrees, + hostId, + matchOptions + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + matchOptions + ) + if ( + areWorktreesEqual(s.worktreesByRepo[r.id], mergedWorktrees) && + areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[r.id], mergedDetected) && + removedIds.length === 0 + ) { + return s + } + return { + worktreesByRepo: { ...s.worktreesByRepo, [r.id]: mergedWorktrees }, + detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: mergedDetected }, + sortEpoch: s.sortEpoch + 1, + ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) + } + }) + }) return } @@ -1645,24 +1889,51 @@ export const createWorktreeSlice: StateCreator | { repoId: string; ok: false } > => { try { + const hostId = getRepoExecutionHostId(r) const detected = await listDetectedWorktreesForRepo( - settingsForRepoOwner(get(), r.id), + settingsForKnownRepoOwner(get().settings, r), r.id ) - const list = toVisibleWorktrees(detected, repoHostId(get(), r.id)) + const list = toVisibleWorktrees(detected, hostId) const current = get().worktreesByRepo[r.id] + const currentMatchOptions = worktreeHostMatchOptions(get(), r.id, hostId) + const currentForHost = (current ?? []).filter((worktree) => + worktreeMatchesHost(worktree, hostId, currentMatchOptions) + ) if ( - !areWorktreesEqual(current, list) && - !(list.length === 0 && current && current.length > 0 && !detected.authoritative) + !areWorktreesEqual(currentForHost, list) && + !(list.length === 0 && currentForHost.length > 0 && !detected.authoritative) ) { - set((s) => ({ - worktreesByRepo: { ...s.worktreesByRepo, [r.id]: list }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: detected }, - sortEpoch: s.sortEpoch + 1 - })) + set((s) => { + const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) + return { + worktreesByRepo: { + ...s.worktreesByRepo, + [r.id]: mergeWorktreesForHost(s.worktreesByRepo[r.id], list, hostId, matchOptions) + }, + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [r.id]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + matchOptions + ) + }, + sortEpoch: s.sortEpoch + 1 + } + }) } else { set((s) => ({ - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: detected } + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [r.id]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + worktreeHostMatchOptions(s, r.id, hostId) + ) + } })) } return { repoId: r.id, ok: detected.authoritative, detected } @@ -2423,6 +2694,9 @@ export const createWorktreeSlice: StateCreator // assemblies (some unit tests) omit the generation slices. get().prunePullRequestGenerationRecords?.(liveWorktreeKeys) get().pruneCommitMessageGenerationRecords?.(liveWorktreeKeys) + // Why: Source Control may be unmounted during deletion, so its local + // prune effect cannot be the only stale-draft cleanup path. + clearSessionCommitDraftForWorktree(worktreeId) const preservedBranch = removalResult?.preservedBranch if (preservedBranch) { showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 2df68373451..b977489b4bc 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -19,9 +19,9 @@ export const AI_VAULT_AGENTS = [ ] as const satisfies readonly TuiAgent[] export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number] -export type AiVaultScope = 'workspace' | 'all' +export type AiVaultScope = 'workspace' | 'project' | 'all' export type AiVaultSort = 'updated' | 'created' -export type AiVaultGroup = 'folder' | 'agent' +export type AiVaultGroup = 'project' | 'folder' | 'agent' export const AI_VAULT_AGENT_LABELS = { claude: 'Claude', diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 1228d7bbdd6..e69917db03e 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -21,6 +21,7 @@ import { normalizeKeybindingListForAction, normalizeKeybindingList } from './keybindings' +import type { KeybindingActionId, KeybindingPlatform } from './keybindings' import { ALL_TUI_AGENTS } from './tui-agent-display-names' describe('keybindings', () => { @@ -250,6 +251,22 @@ describe('keybindings', () => { 'linux' ) ).toBe(false) + expect( + keybindingMatchesAction( + 'tab.rename', + { + key: 'r', + code: 'KeyR', + meta: true, + control: false, + alt: false, + shift: false + }, + 'darwin', + undefined, + { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } + ) + ).toBe(false) // Why: tab.rename (Mod+R) intentionally shares its binding with // browser.reload, but the two live in different scopes (tabs vs browser), @@ -439,6 +456,101 @@ describe('keybindings', () => { ) }) + it('keeps the sleeping-workspaces toggle unassigned until users customize it', () => { + const binding = { + key: 's', + code: 'KeyS', + control: true, + meta: false, + alt: true, + shift: false + } + + // Ships unbound on every platform (issue #5209): assign-it-yourself. + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'darwin')).toEqual( + [] + ) + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'linux')).toEqual( + [] + ) + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'win32')).toEqual( + [] + ) + expect(keybindingMatchesAction('sidebar.sleepingWorkspaces.toggle', binding, 'linux')).toBe( + false + ) + expect( + keybindingMatchesAction('sidebar.sleepingWorkspaces.toggle', binding, 'linux', { + 'sidebar.sleepingWorkspaces.toggle': ['Mod+Alt+S'] + }) + ).toBe(true) + + const definition = getKeybindingDefinition('sidebar.sleepingWorkspaces.toggle') + expect(definition?.title).toBe('Toggle Sleeping Workspaces') + expect(definition?.searchKeywords).toEqual( + expect.arrayContaining(['sleeping', 'workspaces', 'filter']) + ) + }) + + it('defines floating workspace panel action metadata', () => { + const actionIds = [ + 'floatingWorkspace.maximize' as KeybindingActionId, + 'floatingWorkspace.minimize' as KeybindingActionId + ] as const + + for (const actionId of actionIds) { + expect(getKeybindingDefinition(actionId), actionId).toMatchObject({ id: actionId }) + } + }) + + it('assigns the floating workspace maximize default only on macOS', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + expect(getEffectiveKeybindingsForAction(maximizeAction, 'darwin')).toEqual(['Mod+Alt+Shift+A']) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'linux')).toEqual([]) + expect(getEffectiveKeybindingsForAction(maximizeAction, 'win32')).toEqual([]) + }) + + it('captures and round-trips the macOS Option-composed maximize chord', () => { + const maximizeAction = 'floatingWorkspace.maximize' as KeybindingActionId + + // Why: macOS Option+A composes to a glyph (å), so capture must resolve the + // chord through the physical-code fallback rather than the composed key, + // matching the matcher so a user override round-trips to the same binding. + const macComposedMaximize = { + key: 'å', + code: 'KeyA', + meta: true, + control: false, + alt: true, + shift: true + } + expect(keybindingFromInput(macComposedMaximize, 'darwin')).toEqual({ + ok: true, + value: 'Mod+Alt+Shift+A' + }) + expect(keybindingMatchesAction(maximizeAction, macComposedMaximize, 'darwin')).toBe(true) + // The captured override formats back to the same effective shortcut. + expect( + getEffectiveKeybindingsForAction(maximizeAction, 'darwin', { + [maximizeAction]: ['Mod+Alt+Shift+A'] + }) + ).toEqual(['Mod+Alt+Shift+A']) + expect(formatKeybindingList(['Mod+Alt+Shift+A'], 'darwin')).toBe('⌘⌥⇧A') + }) + + it('leaves floating workspace minimize unassigned because floating terminal toggle owns show and hide', () => { + const platforms: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] + const minimizeAction = 'floatingWorkspace.minimize' as KeybindingActionId + + for (const platform of platforms) { + expect(getEffectiveKeybindingsForAction(minimizeAction, platform)).toEqual([]) + } + expect(getEffectiveKeybindingsForAction('floatingTerminal.toggle', 'darwin')).toEqual([ + 'Mod+Alt+A' + ]) + }) + it('defines a macOS-only default for the new agent tab shortcut', () => { expect(getEffectiveKeybindingsForAction('tab.newAgent', 'darwin')).toEqual(['Mod+Alt+T']) expect(getEffectiveKeybindingsForAction('tab.newAgent', 'linux')).toEqual([]) @@ -533,6 +645,32 @@ describe('keybindings', () => { ).toBe(true) }) + it('keeps floating workspace tab shortcuts active in app focus even with terminal-first policy configured', () => { + const panelFocus = { + context: 'app', + terminalShortcutPolicy: 'terminal-first' + } as const + + expect( + keybindingMatchesAction( + 'tab.rename', + { key: 'r', code: 'KeyR', meta: true, control: false, alt: false, shift: false }, + 'darwin', + undefined, + panelFocus + ) + ).toBe(true) + expect( + matchKeybindingDigitIndex( + 'tab.selectByIndex', + { key: '4', code: 'Digit4', meta: false, control: false, alt: true, shift: false }, + 'linux', + undefined, + panelFocus + ) + ).toBe(3) + }) + it('keeps terminal-allowed app shortcuts active in terminal-first mode', () => { const deleteBinding = { key: 'Backspace', diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 4f514c25626..6d4c3e64fb7 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -48,8 +48,11 @@ export type KeybindingActionId = | 'sidebar.sourceControl.toggle' | 'sidebar.checks.toggle' | 'sidebar.ports.toggle' + | 'sidebar.sleepingWorkspaces.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' + | 'floatingWorkspace.maximize' + | 'floatingWorkspace.minimize' | 'zoom.in' | 'zoom.out' | 'zoom.reset' @@ -391,6 +394,26 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ win32: [] } }, + { + id: 'sidebar.sleepingWorkspaces.toggle', + title: 'Toggle Sleeping Workspaces', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'sidebar', + 'sleeping', + 'asleep', + 'workspaces', + 'worktree', + 'filter', + 'show', + 'hide' + ], + // Why: ship unbound — issue #5209 asks to "assign a shortcut", so we avoid + // claiming a cross-platform chord and let users bind it in Settings. + defaultBindings: platformBindings([]) + }, { id: 'sidebar.focusWorktreeList', title: 'Focus worktree list', @@ -408,6 +431,57 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ defaultBindings: platformBindings(['Mod+Alt+A']), allowInTerminal: true }, + { + id: 'floatingWorkspace.maximize', + title: 'Maximize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'maximize', + 'expand' + ], + // Why: pairs with the floatingTerminal.toggle chord (Cmd+Opt+A) so + // maximize/restore lives on the same key anchor and stays one-handed, + // instead of the two-hand reach to Cmd+Opt+ArrowUp. macOS-only default; + // Linux/Windows stay unbound for users to assign. + defaultBindings: { + darwin: ['Mod+Alt+Shift+A'], + linux: [], + win32: [] + }, + allowInTerminal: true + }, + { + id: 'floatingWorkspace.minimize', + title: 'Minimize Floating Workspace Panel', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'floating', + 'workspace', + 'panel', + 'floating workspace', + 'workspace panel', + 'minimize', + 'hide' + ], + // Why: intentionally unbound on every platform. floatingTerminal.toggle + // already owns the default show/hide chord; this action exists only so + // users can bind an explicit "hide the focused panel" shortcut in Settings. + defaultBindings: { + darwin: [], + linux: [], + win32: [] + }, + allowInTerminal: true + }, { id: 'zoom.in', title: 'Zoom In', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 274869eee69..a5e691543a0 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -25,6 +25,7 @@ export type AgentStartupPlan = { expectedProcess: string followupPrompt: string | null launchConfig: SleepingAgentLaunchConfig + launchToken?: string draftPrompt?: string | null env?: Record startupCommandDelivery?: StartupCommandDelivery diff --git a/src/shared/types.ts b/src/shared/types.ts index 20db64abc6c..c56b9af4acc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1125,6 +1125,11 @@ export type PRRefreshOutcome = export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr' +export type GitHubPRRefreshEnqueueResult = + | { kind: 'queued' } + | { kind: 'skipped'; skippedReason: 'validation-denied' | 'validation-backoff' } + | { kind: 'fallback' } + export type GitHubPRRefreshAlias = { cacheKey: string repoId?: string diff --git a/tests/e2e/combined-diff-scroll-restore.spec.ts b/tests/e2e/combined-diff-scroll-restore.spec.ts new file mode 100644 index 00000000000..191f7810f4b --- /dev/null +++ b/tests/e2e/combined-diff-scroll-restore.spec.ts @@ -0,0 +1,403 @@ +import { execFileSync } from 'child_process' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs' +import os from 'os' +import path from 'path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +type CombinedDiffScrollRepo = { + repoPath: string +} + +type ViewportAnchor = { + key: string + index: number + top: number + bottom: number + scrollTop: number + scrollHeight: number + clientHeight: number +} + +type ScrollProbeSample = { + scrollHeight: number + scrollTop: number +} + +const FILE_COUNT = 18 +const ADDED_LINES_PER_FILE = 180 + +function runGit(repoPath: string, args: string[]): void { + execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) +} + +function buildBaseFile(fileIndex: number): string { + return `${Array.from( + { length: 20 }, + (_, lineIndex) => `export const base_${fileIndex}_${lineIndex} = ${lineIndex}` + ).join('\n')}\n` +} + +function buildModifiedFile(fileIndex: number): string { + const added = Array.from( + { length: ADDED_LINES_PER_FILE }, + (_, lineIndex) => `export const changed_${fileIndex}_${lineIndex} = ${fileIndex + lineIndex}` + ).join('\n') + return `${buildBaseFile(fileIndex)}${added}\n` +} + +function createCombinedDiffScrollRepo(): CombinedDiffScrollRepo { + const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-combined-diff-scroll-'))) + runGit(repoPath, ['init']) + runGit(repoPath, ['config', 'user.email', 'e2e@test.local']) + runGit(repoPath, ['config', 'user.name', 'E2E Test']) + + const srcDir = path.join(repoPath, 'src') + mkdirSync(srcDir, { recursive: true }) + for (let index = 0; index < FILE_COUNT; index += 1) { + writeFileSync( + path.join(srcDir, `scroll-${String(index).padStart(2, '0')}.ts`), + buildBaseFile(index) + ) + } + runGit(repoPath, ['add', '-A']) + runGit(repoPath, ['commit', '-m', 'Initial combined diff scroll fixture']) + + for (let index = 0; index < FILE_COUNT; index += 1) { + writeFileSync( + path.join(srcDir, `scroll-${String(index).padStart(2, '0')}.ts`), + buildModifiedFile(index) + ) + } + + return { repoPath } +} + +async function addAndActivateRepo(page: Page, repoPath: string): Promise { + const repoId = await page.evaluate(async (pathToRepo: string) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const addedRepo = await store.getState().addRepoPath(pathToRepo) + if (!addedRepo) { + throw new Error(`isolated combined-diff repo not found: ${pathToRepo}`) + } + return addedRepo.id + }, repoPath) + + await expect + .poll( + () => + page.evaluate(async (targetRepoId: string) => { + const store = window.__store + if (!store) { + return 0 + } + await store.getState().fetchWorktrees(targetRepoId) + return store.getState().worktreesByRepo[targetRepoId]?.length ?? 0 + }, repoId), + { + timeout: 30_000, + message: 'isolated combined-diff worktree did not load' + } + ) + .toBeGreaterThan(0) + + return page.evaluate( + ({ targetRepoId, pathToRepo }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const worktrees = state.worktreesByRepo[targetRepoId] ?? [] + const worktree = worktrees.find((entry) => entry.path === pathToRepo) ?? worktrees[0] + if (!worktree) { + throw new Error(`isolated combined-diff worktree not found: ${pathToRepo}`) + } + state.setActiveRepo(targetRepoId) + state.setActiveWorktree(worktree.id) + return worktree.id + }, + { targetRepoId: repoId, pathToRepo: repoPath } + ) +} + +async function openCombinedDiff(page: Page, worktreeId: string, repoPath: string): Promise { + return page.evaluate( + async ({ wId, pathToRepo }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const status = await window.api.git.status({ worktreePath: pathToRepo }) + const entries = status.entries.filter((entry) => entry.area === 'unstaged') + if (entries.length < 2) { + throw new Error(`expected multiple unstaged entries, received ${entries.length}`) + } + state.setGitStatus(wId, status) + state.openAllDiffs(wId, pathToRepo, undefined, 'unstaged', entries) + + const nextState = store.getState() + const activeGroupId = nextState.activeGroupIdByWorktree[wId] + const activeFileId = nextState.activeFileId + const tab = (nextState.unifiedTabsByWorktree[wId] ?? []).find( + (candidate) => candidate.groupId === activeGroupId && candidate.entityId === activeFileId + ) + if (!tab) { + throw new Error('combined diff tab was not created') + } + return tab.id + }, + { wId: worktreeId, pathToRepo: repoPath } + ) +} + +async function scrollCombinedDiffDeep(page: Page): Promise { + await page.evaluate(() => { + const container = document.querySelector('.combined-diff-scroll-container') + if (!container) { + throw new Error('combined diff scroll container not found') + } + const target = Math.min(7_000, Math.max(0, container.scrollHeight - container.clientHeight - 1)) + container.dispatchEvent( + new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: target }) + ) + container.scrollTop = target + container.dispatchEvent(new Event('scroll', { bubbles: true })) + }) +} + +async function readViewportAnchor(page: Page): Promise { + return page.evaluate(() => { + const container = document.querySelector('.combined-diff-scroll-container') + if (!container) { + return null + } + const containerRect = container.getBoundingClientRect() + const visibleRows = Array.from( + container.querySelectorAll('[data-combined-diff-section-row]') + ) + .map((row) => { + const rect = row.getBoundingClientRect() + const key = row.dataset.combinedDiffSectionKey + const index = Number(row.dataset.index) + if ( + !key || + !Number.isFinite(index) || + rect.height <= 0 || + rect.bottom <= containerRect.top || + rect.top >= containerRect.bottom + ) { + return null + } + return { + key, + index, + top: rect.top - containerRect.top, + bottom: rect.bottom - containerRect.top, + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight + } + }) + .filter((row): row is ViewportAnchor => row !== null) + .sort((a, b) => a.top - b.top) + return visibleRows[0] ?? null + }) +} + +async function waitForStableViewportAnchor(page: Page): Promise { + const startedAt = Date.now() + let lastSignature = '' + let stableSamples = 0 + let lastAnchor: ViewportAnchor | null = null + + while (Date.now() - startedAt < 15_000) { + const anchor = await readViewportAnchor(page) + if (anchor) { + const signature = `${anchor.key}:${Math.round(anchor.top)}:${Math.round( + anchor.bottom + )}:${Math.round(anchor.scrollHeight)}` + if (signature === lastSignature) { + stableSamples += 1 + if (stableSamples >= 2) { + return anchor + } + } else { + lastSignature = signature + stableSamples = 0 + } + lastAnchor = anchor + } + await page.waitForTimeout(100) + } + + throw new Error(`combined diff viewport anchor did not settle: ${JSON.stringify(lastAnchor)}`) +} + +async function startCombinedDiffScrollProbe(page: Page): Promise { + await page.evaluate(() => { + type CombinedDiffScrollProbe = { + samples: ScrollProbeSample[] + stop: () => void + } + const targetWindow = window as typeof window & { + __combinedDiffScrollProbe?: CombinedDiffScrollProbe + } + targetWindow.__combinedDiffScrollProbe?.stop() + + const container = document.querySelector('.combined-diff-scroll-container') + if (!container) { + throw new Error('combined diff scroll container not found') + } + + const samples: ScrollProbeSample[] = [] + const record = (): void => { + samples.push({ + scrollHeight: container.scrollHeight, + scrollTop: container.scrollTop + }) + } + container.addEventListener('scroll', record, { passive: true }) + record() + targetWindow.__combinedDiffScrollProbe = { + samples, + stop: () => container.removeEventListener('scroll', record) + } + }) +} + +async function stopCombinedDiffScrollProbe(page: Page): Promise { + return page.evaluate(() => { + type CombinedDiffScrollProbe = { + samples: ScrollProbeSample[] + stop: () => void + } + const targetWindow = window as typeof window & { + __combinedDiffScrollProbe?: CombinedDiffScrollProbe + } + const probe = targetWindow.__combinedDiffScrollProbe + if (!probe) { + return [] + } + probe.stop() + delete targetWindow.__combinedDiffScrollProbe + return probe.samples + }) +} + +async function wheelCombinedDiffDown(page: Page): Promise { + const container = page.locator('.combined-diff-scroll-container') + const box = await container.boundingBox() + if (!box) { + throw new Error('combined diff scroll container bounds not found') + } + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await startCombinedDiffScrollProbe(page) + for (let index = 0; index < 12; index += 1) { + await page.mouse.wheel(0, 520) + await page.waitForTimeout(35) + } + await page.waitForTimeout(400) + return stopCombinedDiffScrollProbe(page) +} + +function getLargestBackwardScrollJump(samples: readonly ScrollProbeSample[]): number { + let largestBackwardJump = 0 + for (let index = 1; index < samples.length; index += 1) { + largestBackwardJump = Math.max( + largestBackwardJump, + samples[index - 1].scrollTop - samples[index].scrollTop + ) + } + return largestBackwardJump +} + +async function clickVisibleDiffLine(page: Page): Promise { + const linePoint = await page.evaluate(() => { + const container = document.querySelector('.combined-diff-scroll-container') + if (!container) { + throw new Error('combined diff scroll container not found') + } + const containerRect = container.getBoundingClientRect() + const visibleLine = Array.from( + container.querySelectorAll('.monaco-diff-editor .view-line') + ).find((line) => { + const rect = line.getBoundingClientRect() + return ( + rect.height > 0 && + rect.bottom > containerRect.top && + rect.top < containerRect.bottom && + rect.right > containerRect.left && + rect.left < containerRect.right + ) + }) + if (!visibleLine) { + throw new Error('visible combined diff line not found') + } + const rect = visibleLine.getBoundingClientRect() + return { + x: rect.left + Math.min(12, Math.max(1, rect.width / 2)), + y: rect.top + rect.height / 2 + } + }) + + await page.mouse.click(linePoint.x, linePoint.y) +} + +test.describe('Combined diff scroll restore', () => { + test.describe.configure({ mode: 'serial' }) + test.use({ seedTestRepo: false }) + + test('keeps the visible section anchored after switching tabs', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const fixture = createCombinedDiffScrollRepo() + + try { + const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) + const diffTabId = await openCombinedDiff(orcaPage, worktreeId, fixture.repoPath) + await expect(orcaPage.locator('.combined-diff-scroll-container')).toBeVisible() + await expect(orcaPage.getByText(`${FILE_COUNT} changed files`)).toBeVisible() + + await scrollCombinedDiffDeep(orcaPage) + await waitForStableViewportAnchor(orcaPage) + const activeScrollSamples = await wheelCombinedDiffDown(orcaPage) + expect(activeScrollSamples.length).toBeGreaterThan(2) + expect(getLargestBackwardScrollJump(activeScrollSamples)).toBeLessThan(120) + + const beforeSwitch = await waitForStableViewportAnchor(orcaPage) + expect(beforeSwitch.index).toBeGreaterThan(0) + + await orcaPage.evaluate((wId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.getState().createTab(wId) + }, worktreeId) + await expect(orcaPage.locator('.combined-diff-scroll-container')).toHaveCount(0) + + await orcaPage.locator(`[data-tab-id="${diffTabId}"]`).click({ force: true }) + await expect(orcaPage.locator('.combined-diff-scroll-container')).toBeVisible() + const afterSwitch = await waitForStableViewportAnchor(orcaPage) + + expect(afterSwitch.key).toBe(beforeSwitch.key) + expect(Math.abs(afterSwitch.top - beforeSwitch.top)).toBeLessThan(80) + + await clickVisibleDiffLine(orcaPage) + const afterLineClick = await waitForStableViewportAnchor(orcaPage) + + expect(afterLineClick.key).toBe(afterSwitch.key) + expect(Math.abs(afterLineClick.top - afterSwitch.top)).toBeLessThan(80) + } finally { + rmSync(fixture.repoPath, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/e2e/source-control-commit-draft-persistence.spec.ts b/tests/e2e/source-control-commit-draft-persistence.spec.ts new file mode 100644 index 00000000000..5e8fbe770c3 --- /dev/null +++ b/tests/e2e/source-control-commit-draft-persistence.spec.ts @@ -0,0 +1,172 @@ +import { execFileSync } from 'child_process' +import { rmSync, writeFileSync } from 'fs' +import os from 'os' +import path from 'path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +type E2eWorktree = { + branchName: string + worktreePath: string +} + +function createWorktreeWithStagedChange(repoPath: string): E2eWorktree { + const branchName = `e2e-commit-draft-${Date.now()}-${Math.random().toString(16).slice(2)}` + const worktreePath = path.join(os.tmpdir(), branchName) + execFileSync('git', ['worktree', 'add', worktreePath, '-b', branchName], { + cwd: repoPath, + stdio: 'pipe' + }) + writeFileSync( + path.join(worktreePath, 'README.md'), + '# Commit Draft Persistence E2E\n\nPreserve this draft across remounts.\n' + ) + execFileSync('git', ['add', 'README.md'], { cwd: worktreePath, stdio: 'pipe' }) + return { branchName, worktreePath } +} + +function cleanupWorktree(repoPath: string, worktreePath: string, branchName: string): void { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: repoPath, + stdio: 'pipe' + }) + } catch { + try { + rmSync(worktreePath, { recursive: true, force: true }) + execFileSync('git', ['worktree', 'prune'], { cwd: repoPath, stdio: 'pipe' }) + } catch { + // Best effort: the branch delete below is still worth attempting. + } + } + try { + execFileSync('git', ['branch', '-D', branchName], { cwd: repoPath, stdio: 'pipe' }) + } catch { + // The branch may already be gone when git prunes it with the worktree. + } +} + +async function openSourceControlForWorktree( + page: Parameters[0], + repoPath: string, + targetWorktreePath: string +): Promise { + await page.evaluate( + async ({ repoPath, targetWorktreePath }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + await state.fetchRepos() + const repo = store.getState().repos.find((entry) => entry.path === repoPath) + if (!repo) { + throw new Error(`Seeded E2E repo was not registered: ${repoPath}`) + } + + const listedWorktrees = await window.api.worktrees.list({ repoId: repo.id }) + store.setState((current) => ({ + worktreesByRepo: { + ...current.worktreesByRepo, + [repo.id]: listedWorktrees + } + })) + + const normalizeMacTmpPath = (value: string): string => + value.startsWith('/private/var/') ? value.slice('/private'.length) : value + const worktree = listedWorktrees.find( + (entry) => normalizeMacTmpPath(entry.path) === normalizeMacTmpPath(targetWorktreePath) + ) + if (!worktree) { + throw new Error( + `E2E worktree was not loaded: ${targetWorktreePath}; listed=${listedWorktrees + .map((entry) => entry.path) + .join(', ')}` + ) + } + + store.getState().setActiveWorktree(worktree.id) + const status = await window.api.git.status({ worktreePath: worktree.path }) + store.getState().setGitStatus(worktree.id, status) + store.getState().setRightSidebarOpen(true) + store.getState().setRightSidebarTab('source-control') + }, + { repoPath, targetWorktreePath } + ) + + await expect + .poll( + async () => + page.evaluate(() => { + const state = window.__store?.getState() + return Boolean(state?.rightSidebarOpen && state?.rightSidebarTab === 'source-control') + }), + { timeout: 5_000 } + ) + .toBe(true) +} + +test.describe('Source Control commit draft persistence', () => { + test('preserves a typed draft when the sidebar tab remounts', async ({ + orcaPage, + testRepoPath + }) => { + let firstWorktree: E2eWorktree | null = null + let secondWorktree: E2eWorktree | null = null + + try { + firstWorktree = createWorktreeWithStagedChange(testRepoPath) + secondWorktree = createWorktreeWithStagedChange(testRepoPath) + await waitForSessionReady(orcaPage) + await openSourceControlForWorktree(orcaPage, testRepoPath, firstWorktree.worktreePath) + + const textarea = orcaPage.getByRole('textbox', { name: 'Commit message' }) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + + const draft = 'fix: keep draft after leaving Source Control' + await textarea.fill(draft) + await expect(textarea).toHaveValue(draft) + + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('explorer') + }) + await expect + .poll( + async () => orcaPage.evaluate(() => window.__store?.getState().rightSidebarTab ?? null), + { timeout: 5_000 } + ) + .toBe('explorer') + await expect(textarea).toBeHidden() + + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('source-control') + }) + await expect + .poll( + async () => orcaPage.evaluate(() => window.__store?.getState().rightSidebarTab ?? null), + { timeout: 5_000 } + ) + .toBe('source-control') + + await expect(textarea).toBeVisible({ timeout: 10_000 }) + await expect(textarea).toHaveValue(draft) + + await openSourceControlForWorktree(orcaPage, testRepoPath, secondWorktree.worktreePath) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + await expect(textarea).toHaveValue('') + + await openSourceControlForWorktree(orcaPage, testRepoPath, firstWorktree.worktreePath) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + await expect(textarea).toHaveValue(draft) + } finally { + for (const worktree of [firstWorktree, secondWorktree]) { + if (worktree) { + cleanupWorktree(testRepoPath, worktree.worktreePath, worktree.branchName) + } + } + } + }) +}) diff --git a/tests/e2e/terminal-screenshot-diff.ts b/tests/e2e/terminal-screenshot-diff.ts new file mode 100644 index 00000000000..1a3819a2691 --- /dev/null +++ b/tests/e2e/terminal-screenshot-diff.ts @@ -0,0 +1,47 @@ +import { PNG } from 'pngjs' + +export type ScreenshotDiffSummary = { + matches: boolean + diffPixels: number + diffRatio: number + width: number + height: number +} + +export function compareTerminalScreenshots( + baselineBuffer: Buffer, + candidateBuffer: Buffer +): ScreenshotDiffSummary { + const baseline = PNG.sync.read(baselineBuffer) + const candidate = PNG.sync.read(candidateBuffer) + if (baseline.width !== candidate.width || baseline.height !== candidate.height) { + return { + matches: false, + diffPixels: Number.POSITIVE_INFINITY, + diffRatio: Number.POSITIVE_INFINITY, + width: candidate.width, + height: candidate.height + } + } + + let diffPixels = 0 + for (let offset = 0; offset < baseline.data.length; offset += 4) { + const redDiff = Math.abs((baseline.data[offset] ?? 0) - (candidate.data[offset] ?? 0)) + const greenDiff = Math.abs((baseline.data[offset + 1] ?? 0) - (candidate.data[offset + 1] ?? 0)) + const blueDiff = Math.abs((baseline.data[offset + 2] ?? 0) - (candidate.data[offset + 2] ?? 0)) + const alphaDiff = Math.abs((baseline.data[offset + 3] ?? 0) - (candidate.data[offset + 3] ?? 0)) + if (redDiff + greenDiff + blueDiff + alphaDiff > 48) { + diffPixels += 1 + } + } + + const pixelCount = baseline.width * baseline.height + const diffRatio = pixelCount > 0 ? diffPixels / pixelCount : Number.POSITIVE_INFINITY + return { + matches: diffRatio <= 0.015, + diffPixels, + diffRatio, + width: baseline.width, + height: baseline.height + } +} diff --git a/tests/e2e/terminal-tab-screenshot.ts b/tests/e2e/terminal-tab-screenshot.ts new file mode 100644 index 00000000000..506ed1a200f --- /dev/null +++ b/tests/e2e/terminal-tab-screenshot.ts @@ -0,0 +1,21 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' + +function tabScreenLocator(page: Page, tabId: string): ReturnType { + return page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first() +} + +export async function captureStableTabScreenshot(page: Page, tabId: string): Promise { + const screen = tabScreenLocator(page, tabId) + await expect(screen).toBeVisible() + let previous = await screen.screenshot({ animations: 'disabled' }) + for (let attempt = 0; attempt < 10; attempt += 1) { + await page.waitForTimeout(250) + const next = await screen.screenshot({ animations: 'disabled' }) + if (next.equals(previous)) { + return next + } + previous = next + } + throw new Error(`Terminal surface for tab ${tabId} did not stabilize for screenshot`) +} diff --git a/tests/e2e/terminal-tab-switch-sigwinch-restore.spec.ts b/tests/e2e/terminal-tab-switch-sigwinch-restore.spec.ts new file mode 100644 index 00000000000..75d65bea2d9 --- /dev/null +++ b/tests/e2e/terminal-tab-switch-sigwinch-restore.spec.ts @@ -0,0 +1,294 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager +} from './helpers/terminal' + +const SIGWINCH_PROBE_PAGE = 100 +const SIGWINCH_PROBE_ROWS = 12 + +type HiddenOutputDebugSnapshot = { + hiddenRendererSkipCount: number +} + +type HiddenOutputRecoveryWindow = Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean + } + __terminalPtyOutputDebug?: { + reset: () => void + snapshot: () => HiddenOutputDebugSnapshot + } + __terminalHiddenSnapshotOverride?: { + setPending: ( + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } + ) => void + resolve: (ptyId: string) => void + } +} + +function buildSigwinchProbeRows(page: number): string { + return Array.from( + { length: SIGWINCH_PROBE_ROWS }, + (_, index) => `row ${String(page + index).padStart(3, '0')}` + ).join('\r\n') +} + +function buildSigwinchResetProbeCommand(): string { + const script = [ + 'let armed=false', + `let page=${SIGWINCH_PROBE_PAGE}`, + "const topLabel=['TOP','AFTER','SIGWINCH'].join('_')", + `function paint(label){process.stdout.write('\\x1b[?1049h\\x1b[2J\\x1b[H'+label+' page='+page+'\\r\\n'+Array.from({length:${SIGWINCH_PROBE_ROWS}},(_,i)=>'row '+String(page+i).padStart(3,'0')).join('\\r\\n'))}`, + "paint('VISIBLE_BEFORE_SWITCH')", + "process.stdin.setEncoding('utf8')", + "process.stdin.on('data',data=>{if(data.includes('ARM_SIGWINCH_PROBE')){armed=true;paint('ARMED_BEFORE_SWITCH')}})", + "process.on('SIGWINCH',()=>{if(armed===false)return;page=0;paint(topLabel)})", + 'setInterval(()=>{},1000)' + ].join(';') + return `node -e ${JSON.stringify(script)}` +} + +function buildSigwinchResetProbeSnapshot(label: string): string { + return `\x1b[?1049h\x1b[2J\x1b[H${label} page=${SIGWINCH_PROBE_PAGE}\r\n${buildSigwinchProbeRows(SIGWINCH_PROBE_PAGE)}` +} + +async function createAgentMarkedTerminalTab( + page: Page, + agent: 'codex', + command: string +): Promise { + const worktreeId = (await getActiveWorktreeId(page))! + return page.evaluate( + ({ worktreeId, agent, command }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { + launchAgent: agent + }) + state.queueTabStartupCommand(tab.id, { + command, + launchAgent: agent, + telemetry: { + agent_kind: agent, + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { worktreeId, agent, command } + ) +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveTab(id) + store.getState().setActiveTabType('terminal') + }, tabId) + await expect + .poll( + () => + page + .locator(`[data-testid="sortable-tab"][data-active="true"]`) + .getAttribute('data-tab-id'), + { + timeout: 3_000 + } + ) + .toBe(tabId) +} + +async function waitForPanePtyIdOnTab(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId), + { timeout: 15_000, message: `Pane for tab ${tabId} did not receive a PTY binding` } + ) + .not.toBeNull() +} + +async function readPaneIdentityOnTab( + page: Page, + tabId: string +): Promise<{ leafId: string; ptyId: string; cols: number; rows: number }> { + const identity = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + return { + leafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null, + cols: pane.terminal.cols, + rows: pane.terminal.rows + } + }, tabId) + if (!identity?.leafId || !identity.ptyId) { + throw new Error(`Pane identity for tab ${tabId} is incomplete`) + } + return { + leafId: identity.leafId, + ptyId: identity.ptyId, + cols: identity.cols, + rows: identity.rows + } +} + +async function resetHiddenOutputDebug(page: Page): Promise { + await page.evaluate(() => { + ;(window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.reset() + }) +} + +async function readHiddenOutputDebug(page: Page): Promise { + return page.evaluate(() => { + return (window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.snapshot() ?? null + }) +} + +async function injectPaneData( + page: Page, + paneKey: string, + data: string, + meta?: { seq?: number; rawLength?: number } +): Promise { + const injected = await page.evaluate( + ({ paneKey, data, meta }) => + (window as HiddenOutputRecoveryWindow).__terminalPtyDataInjection?.inject( + paneKey, + data, + meta + ) ?? false, + { paneKey, data, meta } + ) + if (!injected) { + throw new Error(`No terminal PTY data injector registered for ${paneKey}`) + } +} + +async function setHiddenSnapshotOverride( + page: Page, + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } +): Promise { + await page.evaluate( + ({ ptyId, snapshot }) => { + const api = (window as HiddenOutputRecoveryWindow).__terminalHiddenSnapshotOverride + if (!api) { + throw new Error('Hidden snapshot override API unavailable') + } + api.setPending(ptyId, snapshot) + api.resolve(ptyId) + }, + { ptyId, snapshot } + ) +} + +test.describe('Terminal tab switch SIGWINCH restore', () => { + test('keeps an alternate-screen Codex viewport after hidden snapshot replay', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const agentTabId = await createAgentMarkedTerminalTab( + orcaPage, + 'codex', + buildSigwinchResetProbeCommand() + ) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, agentTabId) + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'SIGWINCH probe TUI did not paint its initial scrolled page' + }) + .toContain(`VISIBLE_BEFORE_SWITCH page=${SIGWINCH_PROBE_PAGE}`) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, agentTabId) + await sendToTerminal(orcaPage, paneIdentity.ptyId, 'ARM_SIGWINCH_PROBE\n') + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'SIGWINCH probe TUI did not arm after startup settled' + }) + .toContain(`ARMED_BEFORE_SWITCH page=${SIGWINCH_PROBE_PAGE}`) + await orcaPage.waitForTimeout(1_200) + const armedContentAfterSettle = await getTerminalContent(orcaPage, 8_000) + expect(armedContentAfterSettle).not.toContain('TOP_AFTER_SIGWINCH page=0') + const paneKey = `${agentTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const hiddenFrame = ['\x1b[?2026h', 'hidden probe frame', '\x1b[?2026l'].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Codex probe hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: buildSigwinchResetProbeSnapshot('RESTORED_SNAPSHOT'), + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, agentTabId) + + await expect + .poll( + async () => { + const content = await getTerminalContent(orcaPage, 8_000) + if (content.includes('TOP_AFTER_SIGWINCH page=0')) { + return 'top' + } + return content.includes(`RESTORED_SNAPSHOT page=${SIGWINCH_PROBE_PAGE}`) + ? 'snapshot' + : 'pending' + }, + { + timeout: 10_000, + message: 'hidden snapshot replay did not restore the probe TUI page' + } + ) + .toBe('snapshot') + await orcaPage.waitForTimeout(1_200) + const contentAfterSettle = await getTerminalContent(orcaPage, 8_000) + expect(contentAfterSettle).toContain(`RESTORED_SNAPSHOT page=${SIGWINCH_PROBE_PAGE}`) + expect(contentAfterSettle).not.toContain('TOP_AFTER_SIGWINCH page=0') + }) +}) diff --git a/tests/e2e/terminal-tab-switch-visual-restore.spec.ts b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts new file mode 100644 index 00000000000..d154882ec23 --- /dev/null +++ b/tests/e2e/terminal-tab-switch-visual-restore.spec.ts @@ -0,0 +1,867 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager +} from './helpers/terminal' +import { compareTerminalScreenshots } from './terminal-screenshot-diff' +import { captureStableTabScreenshot } from './terminal-tab-screenshot' + +const SILENT_FOREGROUND_COMMAND = 'node -e "setInterval(() => {}, 1000)"\r' +const TAB_A_GLYPH_ROW = 'abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~' +const TAB_B_GLYPH_ROW = 'ZYXWVUTSRQPONMLKJIHGFEDCBA 9876543210 !?^"\'();:,.|$_-' + +type TabTerminalGeometry = { + tabId: string + overlayWidth: number + overlayHeight: number + overlayDisplay: string + cols: number + rows: number + cellWidth: number + screenWidth: number + screenRight: number + rowRight: number + contentWidthRatio: number + markerPresent: boolean + hasWebgl: boolean +} + +const TAB_SWITCH_MARKER_PREFIX = 'TAB_SWITCH_VISUAL_RESTORE' + +type TerminalOutputSchedulerSnapshot = { + backgroundEnqueueCount: number + scheduledDrainCount: number + queuedChars: number +} + +type SchedulerDebugWindow = Window & { + __terminalOutputSchedulerDebug?: { + reset: () => void + snapshot: () => TerminalOutputSchedulerSnapshot + } +} + +type HiddenOutputDebugSnapshot = { + hiddenRendererSkipCount: number + hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number +} + +type HiddenOutputRecoveryWindow = Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean + } + __terminalPtyOutputDebug?: { + reset: () => void + snapshot: () => HiddenOutputDebugSnapshot + } + __terminalHiddenSnapshotOverride?: { + setPending: ( + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } + ) => void + resolve: (ptyId: string) => void + clear: (ptyId: string) => void + } +} + +async function forceWebglOnActiveTab(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { + ...state.settings, + terminalGpuAcceleration: 'on' + } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on') + }) +} + +async function ensureTwoTerminalTabs( + page: Page +): Promise<{ firstTabId: string; secondTabId: string }> { + const worktreeId = (await getActiveWorktreeId(page))! + if ((await page.locator('[data-testid="sortable-tab"]').count()) < 2) { + await page.getByRole('button', { name: 'New tab' }).click({ force: true }) + await page + .getByRole('menuitem', { name: /New Terminal/i }) + .first() + .click({ force: true }) + await expect + .poll(() => page.locator('[data-testid="sortable-tab"]').count(), { timeout: 5_000 }) + .toBeGreaterThanOrEqual(2) + } + const firstTabId = (await getActiveTabId(page))! + const secondTabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tabs = state.tabsByWorktree[worktreeId] ?? [] + const other = tabs.find((tab) => tab.id !== state.activeTabId) + return other?.id ?? null + }, worktreeId) + if (!secondTabId) { + throw new Error('Expected a second terminal tab') + } + return { firstTabId, secondTabId } +} + +async function createAgentMarkedTerminalTab( + page: Page, + agent: 'codex' | 'grok', + command: string +): Promise { + const worktreeId = (await getActiveWorktreeId(page))! + return page.evaluate( + ({ worktreeId, agent, command }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { + launchAgent: agent + }) + state.queueTabStartupCommand(tab.id, { + command, + launchAgent: agent, + telemetry: { + agent_kind: agent, + launch_source: 'tab_bar_quick_launch', + request_kind: 'new' + } + }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, + { worktreeId, agent, command } + ) +} + +async function createCodexMarkedTerminalTab(page: Page): Promise { + return createAgentMarkedTerminalTab(page, 'codex', 'node -e "setInterval(() => {}, 1000)"') +} + +async function createGrokMarkedTerminalTab(page: Page): Promise { + return createAgentMarkedTerminalTab(page, 'grok', 'node -e "setInterval(() => {}, 1000)"') +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setActiveTab(id) + store.getState().setActiveTabType('terminal') + }, tabId) + await expect + .poll( + () => + page + .locator(`[data-testid="sortable-tab"][data-active="true"]`) + .getAttribute('data-tab-id'), + { + timeout: 3_000 + } + ) + .toBe(tabId) +} + +async function waitForWebglOnTab(page: Page, tabId: string): Promise { + return page + .waitForFunction( + (id) => { + const diagnostics = window.__paneManagers?.get(id)?.getRenderingDiagnostics?.() ?? [] + return diagnostics.some((entry) => entry.hasWebgl) + }, + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) +} + +async function waitForPanePtyIdOnTab(page: Page, tabId: string): Promise { + await expect + .poll( + () => + page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId), + { timeout: 15_000, message: `Pane for tab ${tabId} did not receive a PTY binding` } + ) + .not.toBeNull() + const ptyId = await page.evaluate((id) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane?.container?.dataset?.ptyId ?? null + }, tabId) + if (!ptyId) { + throw new Error(`Pane for tab ${tabId} has no PTY binding`) + } + return ptyId +} + +async function readPaneIdentityOnTab( + page: Page, + tabId: string +): Promise<{ leafId: string; ptyId: string; cols: number; rows: number }> { + const identity = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + return null + } + return { + leafId: pane.container.dataset.leafId ?? null, + ptyId: pane.container.dataset.ptyId ?? null, + cols: pane.terminal.cols, + rows: pane.terminal.rows + } + }, tabId) + if (!identity?.leafId || !identity.ptyId) { + throw new Error(`Pane identity for tab ${tabId} is incomplete`) + } + return { + leafId: identity.leafId, + ptyId: identity.ptyId, + cols: identity.cols, + rows: identity.rows + } +} + +async function resetHiddenOutputDebug(page: Page): Promise { + await page.evaluate(() => { + ;(window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.reset() + }) +} + +async function readHiddenOutputDebug(page: Page): Promise { + return page.evaluate(() => { + return (window as HiddenOutputRecoveryWindow).__terminalPtyOutputDebug?.snapshot() ?? null + }) +} + +async function injectPaneData( + page: Page, + paneKey: string, + data: string, + meta?: { seq?: number; rawLength?: number } +): Promise { + const injected = await page.evaluate( + ({ paneKey, data, meta }) => + (window as HiddenOutputRecoveryWindow).__terminalPtyDataInjection?.inject( + paneKey, + data, + meta + ) ?? false, + { paneKey, data, meta } + ) + if (!injected) { + throw new Error(`No terminal PTY data injector registered for ${paneKey}`) + } +} + +async function setHiddenSnapshotOverride( + page: Page, + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq?: number } +): Promise { + await page.evaluate( + ({ ptyId, snapshot }) => { + const api = (window as HiddenOutputRecoveryWindow).__terminalHiddenSnapshotOverride + if (!api) { + throw new Error('Hidden snapshot override API unavailable') + } + api.setPending(ptyId, snapshot) + api.resolve(ptyId) + }, + { ptyId, snapshot } + ) +} + +async function resetTerminalOutputSchedulerDebug(page: Page): Promise { + await page.evaluate(() => { + const debug = (window as SchedulerDebugWindow).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('Terminal output scheduler debug API unavailable') + } + debug.reset() + }) +} + +async function waitForHiddenOutputSchedulerActivity( + page: Page +): Promise { + await expect + .poll( + () => + page.evaluate(() => { + const snapshot = ( + window as SchedulerDebugWindow + ).__terminalOutputSchedulerDebug?.snapshot() + return snapshot?.backgroundEnqueueCount ?? 0 + }), + { + timeout: 5_000, + message: 'hidden PTY output did not reach the background output scheduler' + } + ) + .toBeGreaterThan(0) + return page.evaluate(() => { + const snapshot = (window as SchedulerDebugWindow).__terminalOutputSchedulerDebug?.snapshot() + if (!snapshot) { + throw new Error('Terminal output scheduler debug API unavailable') + } + return { + backgroundEnqueueCount: snapshot.backgroundEnqueueCount, + scheduledDrainCount: snapshot.scheduledDrainCount, + queuedChars: snapshot.queuedChars + } + }) +} + +async function startHiddenPtyOutputBurst(page: Page, ptyId: string, runId: string): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_PTY_${runId}` + const script = [ + `const marker=${JSON.stringify(marker)};`, + 'setTimeout(()=>{', + 'let frame=0;', + 'const timer=setInterval(()=>{', + 'console.log(`${marker} frame=${String(frame).padStart(3,"0")} abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\\\#@%&*+=~`);', + 'frame+=1;', + 'if(frame>=180) clearInterval(timer);', + '},1);', + '},30);' + ].join('') + await sendToTerminal(page, ptyId, `node -e ${JSON.stringify(script)}\r`) +} + +async function writeStaticTabContent( + page: Page, + tabId: string, + marker: string, + glyphRow: string +): Promise { + await page.evaluate( + async ({ id, marker, glyphRow }) => { + const manager = window.__paneManagers?.get(id) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error(`Pane unavailable for tab ${id}`) + } + const rows = Array.from( + { length: 14 }, + (_, row) => `${marker} row ${row} | ${glyphRow} |\r\n` + ).join('') + await new Promise((resolve) => + pane.terminal.write(`\x1b[2J\x1b[3J\x1b[H\x1b[?25l${rows}`, resolve) + ) + pane.terminal.refresh(0, pane.terminal.rows - 1) + }, + { id: tabId, marker, glyphRow } + ) + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) +} + +async function resetAtlasOnTab(page: Page, tabId: string): Promise { + await page.evaluate((id) => { + window.__paneManagers?.get(id)?.resetWebglTextureAtlases?.() + }, tabId) +} + +async function injectHiddenStreamingBurst(page: Page, tabId: string, runId: string): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + await page.evaluate( + ({ tabId, marker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + // Why: Grok sessions can emit large formatted bursts while the tab is + // hidden; stress the visibility-resume flush path beyond a few lines. + const burst = Array.from({ length: 400 }, (_, frame) => { + const progress = `${'█'.repeat((frame % 16) + 1)}${'░'.repeat(16 - ((frame % 16) + 1))}` + return [ + `hidden_stream frame=${String(frame).padStart(3, '0')} ${marker}`, + `Dimension │ Rating │`, + `status ${frame % 2 === 0 ? 'thinking' : 'streaming'} ${progress}`, + `abcdefghijklmnopqrstuvwxyz 0123456789 []{}<>/\\#@%&*+=~` + ].join('\r\n') + }).join('\r\n') + return new Promise((resolve) => { + pane.terminal.write(`${burst}\r\n`, resolve) + }) + }, + { tabId, marker } + ) +} + +async function readTabTerminalGeometry( + page: Page, + tabId: string, + runId: string +): Promise { + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + return page.evaluate( + ({ tabId, marker }) => { + const overlay = document.querySelector( + `[data-terminal-overlay-tab-id="${tabId}"]` + ) + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const overlayRect = overlay?.getBoundingClientRect() + const screen = pane.container.querySelector('.xterm-screen') + if (!screen) { + throw new Error(`No xterm screen for tab ${tabId}`) + } + const screenRect = screen.getBoundingClientRect() + const cellWidth = pane.terminal._core?._renderService?.dimensions?.css?.cell?.width ?? 0 + const renderedContentWidth = pane.terminal.cols * cellWidth + const rowRight = screenRect.left + renderedContentWidth + const contentWidthRatio = screenRect.width > 0 ? renderedContentWidth / screenRect.width : 0 + const buffer = pane.terminal.buffer.active + let markerPresent = false + for (let row = 0; row < pane.terminal.rows; row += 1) { + const line = buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + if (line.includes(marker)) { + markerPresent = true + break + } + } + const diagnostics = manager?.getRenderingDiagnostics?.() ?? [] + const hasWebgl = diagnostics.some((entry) => entry.hasWebgl) + return { + tabId, + overlayWidth: overlayRect?.width ?? 0, + overlayHeight: overlayRect?.height ?? 0, + overlayDisplay: overlay ? window.getComputedStyle(overlay).display : 'missing', + cols: pane.terminal.cols, + rows: pane.terminal.rows, + cellWidth, + screenWidth: screenRect.width, + screenRight: screenRect.right, + rowRight, + contentWidthRatio, + markerPresent, + hasWebgl + } + }, + { tabId, marker } + ) +} + +function geometryLooksCorrupted(geometry: TabTerminalGeometry): string | null { + if (geometry.overlayDisplay === 'none') { + return 'overlay still display:none after activation' + } + if (geometry.overlayWidth < 200 || geometry.overlayHeight <= 0) { + return `overlay dimensions invalid (${geometry.overlayWidth}x${geometry.overlayHeight}px)` + } + if (geometry.cols < 40 || geometry.rows <= 0) { + return `terminal grid invalid (${geometry.cols}x${geometry.rows})` + } + // Why: half-width bug paints content in only ~50% of the screen; rowRight + // lags far behind screenRight when cols are stale. + if (geometry.contentWidthRatio > 0 && geometry.contentWidthRatio < 0.82) { + return `content width ratio ${geometry.contentWidthRatio.toFixed(3)} < 0.82` + } + if ( + geometry.screenWidth > 0 && + geometry.rowRight < geometry.screenRight - geometry.cellWidth * 4 + ) { + return `rowRight ${geometry.rowRight.toFixed(1)} lags screenRight ${geometry.screenRight.toFixed(1)}` + } + return null +} + +async function captureTabScreenshot( + page: Page, + tabId: string, + testInfo: TestInfo, + label: string +): Promise { + const overlay = page.locator(`[data-terminal-overlay-tab-id="${tabId}"]`) + const path = testInfo.outputPath(`${label}-${tabId}.png`) + await overlay.screenshot({ path }) + await testInfo.attach(`${label}.png`, { path, contentType: 'image/png' }) +} + +test.describe('Terminal tab switch visual restore', () => { + test.describe.configure({ mode: 'serial' }) + + test('keeps full-width geometry after switching away and back', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}` + const firstPtyId = await waitForPanePtyIdOnTab(orcaPage, firstTabId) + await writeStaticTabContent(orcaPage, firstTabId, marker, TAB_A_GLYPH_ROW) + + const baseline = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + expect(baseline.markerPresent).toBe(true) + expect(baseline.overlayWidth).toBeGreaterThan(300) + expect(geometryLooksCorrupted(baseline)).toBeNull() + + const corruptionReports: string[] = [] + await resetTerminalOutputSchedulerDebug(orcaPage) + await startHiddenPtyOutputBurst(orcaPage, firstPtyId, runId) + + for (let cycle = 0; cycle < 12; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + await injectHiddenStreamingBurst(orcaPage, firstTabId, runId) + // Why: rapid back-to-back switches mirror the user's leave/return pattern + // and race the overlay's rAF/50ms refit retries. + await activateTerminalTab(orcaPage, firstTabId) + if (cycle % 3 === 0) { + await activateTerminalTab(orcaPage, secondTabId) + await activateTerminalTab(orcaPage, firstTabId) + } + + // Sample immediately — bug often shows before the 50ms overlay refit retry. + const immediate = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + const immediateIssue = geometryLooksCorrupted(immediate) + if (immediateIssue) { + corruptionReports.push(`cycle ${cycle} immediate: ${immediateIssue}`) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `tab-switch-corrupt-immediate-cycle-${cycle}` + ) + } + + await orcaPage.waitForTimeout(60) + const settled = await readTabTerminalGeometry(orcaPage, firstTabId, runId) + const settledIssue = geometryLooksCorrupted(settled) + if (settledIssue) { + corruptionReports.push(`cycle ${cycle} settled: ${settledIssue}`) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `tab-switch-corrupt-settled-cycle-${cycle}` + ) + } + } + const schedulerActivity = await waitForHiddenOutputSchedulerActivity(orcaPage) + expect(schedulerActivity.scheduledDrainCount).toBeGreaterThan(0) + + if (corruptionReports.length > 0) { + console.log('[tab-switch-repro] corruption reports:', corruptionReports) + } + + expect( + corruptionReports, + corruptionReports.length > 0 + ? `tab switch left stale terminal geometry:\n${corruptionReports.join('\n')}` + : undefined + ).toEqual([]) + }) + + test('keeps geometry after hidden alt-screen TUI redraws during tab switches', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + + const runId = `${Date.now()}` + const finalMarker = `${TAB_SWITCH_MARKER_PREFIX}_${runId}_ALT_24` + + await orcaPage.evaluate( + ({ tabId, finalMarker }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const frames = Array.from({ length: 25 }, (_, frame) => { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${finalMarker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') + }).join('') + return new Promise((resolve) => pane.terminal.write(frames, resolve)) + }, + { tabId: firstTabId, finalMarker } + ) + + const corruptionReports: string[] = [] + for (let cycle = 0; cycle < 6; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + await orcaPage.evaluate( + ({ tabId, finalMarker, cycle }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error(`No terminal pane for tab ${tabId}`) + } + const frame = cycle * 4 + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + const redraw = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + `╭────────────────────────────────────────────────────────────────────╮`, + `│ ${finalMarker} frame ${String(frame).padStart(3, '0')} ${progress} │`, + `│ Dimension │ Rating │`, + `╰────────────────────────────────────────────────────────────────────╯`, + '\x1b[?2026l' + ].join('\r\n') + return new Promise((resolve) => pane.terminal.write(redraw, resolve)) + }, + { tabId: firstTabId, finalMarker, cycle } + ) + await activateTerminalTab(orcaPage, firstTabId) + + const geometry = await readTabTerminalGeometry(orcaPage, firstTabId, `${runId}_ALT`) + const issue = geometryLooksCorrupted(geometry) + if (issue || !geometry.markerPresent) { + corruptionReports.push( + `cycle ${cycle}: ${issue ?? 'marker missing after alt-screen redraw'}` + ) + await captureTabScreenshot( + orcaPage, + firstTabId, + testInfo, + `alt-screen-corrupt-cycle-${cycle}` + ) + } + } + + expect( + corruptionReports, + corruptionReports.length > 0 + ? `alt-screen hidden redraw left corrupted geometry:\n${corruptionReports.join('\n')}` + : undefined + ).toEqual([]) + }) + + test('restores skipped hidden agent output on light tab resume', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const agentTabId = await createCodexMarkedTerminalTab(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, agentTabId) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, agentTabId) + const paneKey = `${agentTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_SKIPPED_AGENT_${runId}` + const hiddenFrame = [ + '\x1b[?2026h', + `${marker} hidden renderer frame`, + 'status=streaming while tab-hidden', + '\x1b[?2026l' + ].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Codex-marked hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: `${marker} restored from main snapshot\r\n`, + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, agentTabId) + + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'light tab resume did not request skipped hidden-output recovery' + }) + .toContain(marker) + }) + + test('restores skipped hidden Grok output on light tab resume', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const shellTabId = (await getActiveTabId(orcaPage))! + const grokTabId = await createGrokMarkedTerminalTab(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPanePtyIdOnTab(orcaPage, grokTabId) + const paneIdentity = await readPaneIdentityOnTab(orcaPage, grokTabId) + const paneKey = `${grokTabId}:${paneIdentity.leafId}` + + await activateTerminalTab(orcaPage, shellTabId) + const runId = `${Date.now()}` + const marker = `${TAB_SWITCH_MARKER_PREFIX}_SKIPPED_GROK_${runId}` + // Why: synchronized-output mode exercises the hidden renderer skip path + // used by agent TUIs before light tab resume requests recovery. + const hiddenFrame = [ + '\x1b[?2026h', + `${marker} hidden renderer frame`, + 'status=streaming while tab-hidden', + '\x1b[?2026l' + ].join('\r\n') + await resetHiddenOutputDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenOutputDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 5_000, + message: 'Grok-marked hidden output did not take the skipped renderer path' + }) + .toBeGreaterThan(0) + await setHiddenSnapshotOverride(orcaPage, paneIdentity.ptyId, { + data: `${marker} restored from main snapshot\r\n`, + cols: paneIdentity.cols, + rows: paneIdentity.rows, + seq: hiddenFrame.length + }) + + await activateTerminalTab(orcaPage, grokTabId) + + await expect + .poll(() => getTerminalContent(orcaPage, 8_000), { + timeout: 10_000, + message: 'light tab resume did not request skipped Grok hidden-output recovery' + }) + .toContain(marker) + }) + + test('keeps returned tab glyphs intact across tab switches', async ({ orcaPage }, testInfo) => { + // Why: screenshot equality catches WebGL atlas corruption on the tab being + // resumed, not just stale cols/rows geometry checks. + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const { firstTabId, secondTabId } = await ensureTwoTerminalTabs(orcaPage) + await forceWebglOnActiveTab(orcaPage) + await activateTerminalTab(orcaPage, firstTabId) + const firstWebgl = await waitForWebglOnTab(orcaPage, firstTabId) + await activateTerminalTab(orcaPage, secondTabId) + await orcaPage.evaluate((id) => { + window.__paneManagers?.get(id)?.setTerminalGpuAcceleration?.('on') + }, secondTabId) + const secondWebgl = await waitForWebglOnTab(orcaPage, secondTabId) + if (!firstWebgl || !secondWebgl) { + test.skip(true, 'WebGL never attached on both tabs') + return + } + + const firstPtyId = await waitForPanePtyIdOnTab(orcaPage, firstTabId) + const secondPtyId = await waitForPanePtyIdOnTab(orcaPage, secondTabId) + await sendToTerminal(orcaPage, firstPtyId, SILENT_FOREGROUND_COMMAND) + await sendToTerminal(orcaPage, secondPtyId, SILENT_FOREGROUND_COMMAND) + await orcaPage.waitForTimeout(1_000) + + const runId = `${Date.now()}` + const markerA = `${TAB_SWITCH_MARKER_PREFIX}_A_${runId}` + const markerB = `${TAB_SWITCH_MARKER_PREFIX}_B_${runId}` + await writeStaticTabContent(orcaPage, firstTabId, markerA, TAB_A_GLYPH_ROW) + await activateTerminalTab(orcaPage, secondTabId) + await writeStaticTabContent(orcaPage, secondTabId, markerB, TAB_B_GLYPH_ROW) + + await activateTerminalTab(orcaPage, firstTabId) + await resetAtlasOnTab(orcaPage, firstTabId) + await orcaPage.waitForTimeout(800) + const baseline = await captureStableTabScreenshot(orcaPage, firstTabId) + + const screenshotMismatches: string[] = [] + for (let cycle = 0; cycle < 8; cycle += 1) { + await activateTerminalTab(orcaPage, secondTabId) + // Why: do not write into the hidden tab here — new bytes would change the + // screenshot even when rendering is healthy. This cycle only exercises the + // suspend/resume + atlas reset path on unchanged content. + await activateTerminalTab(orcaPage, firstTabId) + await orcaPage.waitForTimeout(100) + const afterReturn = await captureStableTabScreenshot(orcaPage, firstTabId) + const diff = compareTerminalScreenshots(baseline, afterReturn) + if (!diff.matches) { + screenshotMismatches.push( + `cycle ${cycle}: ${diff.diffPixels} px (${(diff.diffRatio * 100).toFixed(2)}%)` + ) + await testInfo.attach(`after-return-cycle-${cycle}`, { + body: afterReturn, + contentType: 'image/png' + }) + } + } + + await testInfo.attach('baseline', { body: baseline, contentType: 'image/png' }) + expect( + screenshotMismatches, + screenshotMismatches.length > 0 + ? `returned tab glyphs changed after switch cycles: ${screenshotMismatches.join(', ')}` + : undefined + ).toEqual([]) + }) +})