Merge origin/main into folder style PR

This commit is contained in:
brennanb2025
2026-06-23 16:13:44 -07:00
288 changed files with 23088 additions and 2930 deletions
@@ -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'),
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.4m">
<title>downloads: 1.4m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.5m">
<title>downloads: 1.5m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.4m</text>
<text x="93.5" y="14">1.4m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.5m</text>
<text x="93.5" y="14">1.5m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+137
View File
@@ -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 <number>` 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 <n>`, 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.
@@ -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"
/>
</Pressable>
) : (
@@ -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
@@ -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"')
})
})
@@ -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<String>?
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<String>()
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<String>? {
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<String> = [
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<String> = [
@@ -1697,24 +1976,6 @@ private let valueSettableRoles: Set<String> = [
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<String> = [
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<String>()
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: " ")
}
@@ -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<String>?) -> 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
@@ -2,6 +2,24 @@ import XCTest
@testable import OrcaComputerUseMacOSCore
final class SnapshotRenderingTests: XCTestCase {
func testSkipsUnsupportedAdvertisedAttributes() {
let advertised: Set<String> = ["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)
+1 -1
View File
@@ -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",
@@ -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.
+4 -1
View File
@@ -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:<worktreeId> --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:<worktreeId> --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 <id>`; defaults are `todo`, `in-progress`, `in-review`, `completed`.
## Terminals
Common commands:
@@ -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
// <dbPath>#<sessionId> 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':
@@ -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)
})
})
@@ -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<string, string>
}
const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTitleCacheEntry>>()
export async function parseCodexSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform,
codexHome: string | null = null
): Promise<AiVaultSession | null> {
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, unknown>): string | null {
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
}
function isCodexWorkerSession(payload: Record<string, unknown>): 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, unknown>): 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<string | null> {
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<Map<string, string>> {
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<Map<string, string>> {
const titleBySessionId = new Map<string, string>()
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
}
@@ -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'),
@@ -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<SessionFileDiscovery>[] {
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<readonly string[]> {
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<string[]> {
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 []
}
}
@@ -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/<projectId>/<sessionId>.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'"
)
})
})
@@ -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<string, SessionFileCandidate>()
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
* `<dbPath>#<sessionId>` 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<SessionFileCandidate[]> {
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/<projectId>/<sessionId>.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<SessionFileDiscovery> {
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<string>()
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]
}
}
@@ -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')
})
})
@@ -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 `<dbPath>#<sessionId>`. 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
}
@@ -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')
})
})
@@ -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<string, unknown>)
: 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<string, unknown>)
: 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<AiVaultSession | null> {
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()
}
}
@@ -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<AiVaultSession | null> {
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, unknown>): string | null {
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
}
function isCodexWorkerSession(payload: Record<string, unknown>): 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
@@ -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<SessionFileDiscovery>([
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<SessionFileDiscovery>[] {
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[],
@@ -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 = '<INSTRUCTIONS>'
const HIDDEN_TEXT_BLOCKS = [
@@ -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
+14 -2
View File
@@ -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 <INSTRUCTIONS>' }
{ type: 'text', text: '# AGENTS.md instructions\n\n<INSTRUCTIONS>repo 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'
])
+9
View File
@@ -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<AiVaultListResult> {
+32
View File
@@ -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' })
@@ -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)
})
})
@@ -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')) {
+109 -13
View File
@@ -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<string, EffectiveUpstreamStatusCacheEntry>()
const effectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const retiredEffectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
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<GitUpstreamStatus> {
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()
}
}
}
+101 -1
View File
@@ -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: [
+73
View File
@@ -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<string, TrackedUpstreamNullCacheEntry>()
const trackedUpstreamInFlight = new Map<string, Promise<TrackedUpstreamBranch | null>>()
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<TrackedUpstreamBranch | null> {
// 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<TrackedUpstreamBranch | null> {
const args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branchName}@{upstream}`]
try {
+48
View File
@@ -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' }
)
})
})
+39
View File
@@ -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<string, { failures: number; retryAt: number }>()
let lastBackgroundStartAt = 0
const visibleByWindow = new Map<number, { generation: number; keys: Set<string> }>()
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<void> {
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<void> {
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,
@@ -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')
})
})
@@ -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<string, ValidationBackoffEntry>()
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
}
@@ -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' }
)
})
})
+53 -16
View File
@@ -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<unknown>(patchArgs)
if (!r.ok) {
return { ok: false, error: r.error }
@@ -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<void> {
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)
}
}
+30
View File
@@ -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'])
+12 -2
View File
@@ -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<GitStatusResult> => {
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) {
+152
View File
@@ -158,6 +158,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({
}))
import { registerGitHubHandlers } from './github'
import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => 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)
+99 -45
View File
@@ -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<GitHubPRRefreshEnqueueResult, { kind: 'skipped' }>
} {
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
}
+59
View File
@@ -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') {
+10 -5
View File
@@ -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<string[]> {
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<string[]> => {
return detectInstalledAgents(args)
}
ipcMain.handle('preflight:detectAgents', async (_event, args?: PreflightRuntimeContext) =>
detectInstalledAgentsWithShellPathHydration(args)
)
ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => {
@@ -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()
}
+4
View File
@@ -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 })
}
+314 -3
View File
@@ -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<string, (_event: unknown, args: unknown) => 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[]) => {
+86 -6
View File
@@ -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<string>()
const loggedWorktreeListFailures = new Set<string>()
const loggedMalformedWorktreeMetaKeys = new Set<string>()
// 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<string, DetectedWorktreeScanCacheEntry>()
const detectedWorktreeScanInFlight = new Map<string, Promise<GitWorktreeInfo[]>>()
const detectedWorktreeScanGenerations = new Map<string, number>()
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<DetectedWorktreeScanResult> {
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<string>, 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,
+67 -2
View File
@@ -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({
+53 -12
View File
@@ -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<J
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.site.id)
if (shouldThrowAuthError(siteId)) {
if (shouldSurfaceSiteFailure(siteId, entries.length)) {
throw error
}
} else {
+1 -12
View File
@@ -6,6 +6,7 @@ import { isAbsolute, join, posix, win32 } from 'path'
import type { Repo } from '../../shared/types'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import Database from '../sqlite/sync-database'
import { columnExists, tableExists } from './schema-helpers'
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
import type {
OpenCodeUsageAttributedEvent,
@@ -135,18 +136,6 @@ async function yieldToEventLoop(): Promise<void> {
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'
+31
View File
@@ -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)
}
@@ -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'])
+7 -3
View File
@@ -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<GitStatusResult> {
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
}
+6 -1
View File
@@ -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<GitStatusResult>
getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise<GitStatusResult>
checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]>
getHistory(worktreePath: string, options?: GitHistoryOptions): Promise<GitHistoryResult>
commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }>
@@ -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/<drive>/... 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
+27 -12
View File
@@ -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
}
}
+54 -19
View File
@@ -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 = {
+4 -6
View File
@@ -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
}
+62 -1
View File
@@ -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 =
+24 -4
View File
@@ -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',
+2 -1
View File
@@ -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<GitStatusResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
+14 -14
View File
@@ -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<string, WorktreeMeta> = {}
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<string, WorktreeMeta> = {}
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<string, WorktreeMeta> = {}
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<string, WorktreeMeta> = {}
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',
+3 -2
View File
@@ -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 = []
}
+2 -1
View File
@@ -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({
+26
View File
@@ -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',
+16 -3
View File
@@ -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',
@@ -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({
+2 -2
View File
@@ -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',
@@ -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]
)
})
})
@@ -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'),
+3 -1
View File
@@ -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<boolean>
}) => Promise<GitHubPRRefreshEnqueueResult | false>
reportVisiblePRRefreshCandidates: (args: {
candidates: GitHubPRRefreshCandidate[]
generation: number
@@ -2107,6 +2108,7 @@ export type PreloadApi = {
worktreePath: string
connectionId?: string
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
}) => Promise<GitStatusResult>
checkIgnored: (args: {
worktreePath: string
+1
View File
@@ -2536,6 +2536,7 @@ const api = {
worktreePath: string
connectionId?: string
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
checkIgnored: (args: {
worktreePath: string
+174
View File
@@ -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<GitExec>(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<GitExec>(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<GitExec>(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<GitExec>(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<GitExec>(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)
})
})
+16 -9
View File
@@ -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.
}
}
}
@@ -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)
})
})
@@ -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<string, NoEffectiveUpstreamCacheEntry>()
const noEffectiveUpstreamInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const retiredNoEffectiveUpstreamInFlight = new Map<string, Promise<GitUpstreamStatus>>()
const noEffectiveUpstreamWriteGeneration = new Map<string, number>()
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/<branch>; 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<GitUpstreamStatus> {
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
}
+194 -5
View File
@@ -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')
+155 -21
View File
@@ -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<boolean> {
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<boolean> {
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, "'\\''")}'`
}
+38 -1
View File
@@ -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
+12 -2
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
@@ -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({
/>
</div>
<div>
{/* 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. */}
<div className="!mb-2">
<Button
type="button"
variant="ghost"
@@ -828,6 +838,7 @@ export default function NewWorkspaceComposerCard({
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
!advancedOpen && '!mt-2',
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!advancedOpen}
@@ -1043,7 +1054,39 @@ export default function NewWorkspaceComposerCard({
</div>
) : null}
<div className="flex justify-end">
<div
className={cn(
'flex items-center gap-3',
showCreateMultiple ? 'justify-between' : 'justify-end'
)}
>
{showCreateMultiple ? (
<button
type="button"
role="switch"
aria-checked={createMultiple}
onClick={() => onCreateMultipleChange?.(!createMultiple)}
className="group flex w-fit cursor-pointer items-center gap-2 rounded-md text-xs text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span
aria-hidden
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent transition-colors',
createMultiple ? 'bg-foreground' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
createMultiple ? 'translate-x-4' : 'translate-x-0.5'
)}
/>
</span>
<span>
{translate('auto.components.NewWorkspaceComposerCard.createMultiple', 'Create more')}
</span>
</button>
) : null}
<Button
onClick={() => void onCreate()}
disabled={createDisabled}
+380 -207
View File
@@ -40,7 +40,6 @@ import {
RefreshCw,
Send,
UndoDot,
Users,
Wrench,
X
} from 'lucide-react'
@@ -58,7 +57,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,
@@ -155,6 +154,7 @@ import {
filterGitHubPRReviewerCandidates,
getGitHubPRReviewerQueryState
} from '@/components/github/github-pr-reviewer-candidate-filter'
import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers'
import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter'
import {
getCommentBodySubmitState,
@@ -448,25 +448,15 @@ function ReviewerAvatar({
login: string
avatarUrl: string
}): React.JSX.Element {
if (avatarUrl) {
return (
<img
src={avatarUrl}
alt=""
loading="lazy"
decoding="async"
title={login}
className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover"
/>
)
}
return (
<span
<img
src={avatarUrl || githubAvatarUrl(login)}
alt=""
loading="lazy"
decoding="async"
title={login}
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted text-[10px] font-medium text-muted-foreground"
>
{login.slice(0, 1).toUpperCase()}
</span>
className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover"
/>
)
}
@@ -509,6 +499,222 @@ function buildRequestedReviewUsers(
return Array.from(byLogin.values())
}
function PRAssigneesPanel({
item,
repoPath,
projectOrigin,
onMutated
}: {
item: GitHubWorkItem
repoPath: string | null
projectOrigin: PullRequestPageProjectOrigin | undefined
onMutated: () => void
}): React.JSX.Element {
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false)
const [localAssignees, setLocalAssignees] = useState<GitHubAssignableUser[]>(
() => 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 { 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,
repoOwnerSettings
)
const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, repoOwnerSettings)
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,
projectOrigin,
number: item.number,
updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] }
}),
onOptimistic: () => {
setLocalAssignees(nextAssignees)
patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId)
patchProjectRowIfNeeded(nextLogins)
},
onRevert: () => {
setLocalAssignees(prevAssignees)
patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId)
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
]
)
const checkIcon = (
<svg className="size-2.5" viewBox="0 0 12 12" fill="none">
<path
d="M2 6l3 3 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
return (
<section>
<div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span>{translate('auto.components.PullRequestPage.8ff5ae8866', 'Assignees')}</span>
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={!canEditAssignees || isPending('assignees') || repoAssignees.loading}
aria-label={translate('auto.components.PullRequestPage.82c87eceb9', 'Edit assignees')}
className="rounded p-0.5 text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-50"
>
{isPending('assignees') ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<Pencil className="size-3" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="popover-scroll-content scrollbar-sleek w-60 p-1" align="end">
{repoAssignees.error ? (
<div className="px-2 py-3 text-center text-[12px] text-destructive">
{repoAssignees.error}
</div>
) : (
<div>
{repoAssignees.data.map((user) => {
const selected = localAssignees.some(
(assignee) => assignee.login.toLowerCase() === user.login.toLowerCase()
)
return (
<button
key={user.login}
type="button"
onClick={() => handleAssigneeToggle(user.login)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent"
>
<span
className={cn(
'flex size-3.5 items-center justify-center rounded-sm border',
selected
? 'border-primary bg-primary text-primary-foreground'
: 'border-input'
)}
>
{selected && checkIcon}
</span>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="size-5 rounded-full" />
) : null}
<span className="min-w-0 flex-1 text-left">
<span className="block truncate">{user.login}</span>
{user.name ? (
<span className="block truncate text-[11px] text-muted-foreground">
{user.name}
</span>
) : null}
</span>
</button>
)
})}
</div>
)}
</PopoverContent>
</Popover>
</div>
{localAssignees.length === 0 ? (
<div className="text-[12px] text-muted-foreground">
{translate('auto.components.PullRequestPage.1ff5d979df', 'No one assigned')}
</div>
) : (
<ul className="flex flex-col gap-1.5">
{localAssignees.map((assignee) => (
<li key={assignee.login} className="flex min-w-0 items-center gap-2">
<ReviewerAvatar login={assignee.login} avatarUrl={assignee.avatarUrl} />
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
{assignee.login}
</span>
</li>
))}
</ul>
)}
</section>
)
}
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<number | null>(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<void> => {
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 (
<aside className="rounded-lg border border-border/50 bg-card shadow-xs">
<div className="flex h-10 items-center gap-2 border-b border-border/50 px-3">
<Users className="size-3.5 text-muted-foreground" />
<span className="text-[13px] font-medium text-foreground">
{translate('auto.components.PullRequestPage.00d3be6bcd', 'Reviewers')}
</span>
{reviewers.length > 0 ? (
<span className="ml-auto rounded-full border border-border/50 bg-muted/30 px-1.5 py-0.5 text-[11px] tabular-nums text-muted-foreground">
{reviewers.length}
</span>
) : null}
</div>
<div className="px-3 py-2.5">
{loading && !hasReviewerMetadata ? (
<div className="flex items-center gap-2 py-1 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')}
</div>
) : reviewers.length > 0 ? (
<div className="flex flex-col gap-2">
{reviewers.map((reviewer) => {
const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase())
return (
<div key={reviewer.login} className="flex min-w-0 items-center gap-2">
<ReviewerAvatar login={reviewer.login} avatarUrl={reviewer.avatarUrl} />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">
{reviewer.login}
</div>
{reviewer.name ? (
<div className="truncate text-[11px] text-muted-foreground">
{reviewer.name}
</div>
) : null}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{reviewer.stateLabel}
</span>
{canRemoveReviewer ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
disabled={submitting || !canRequestReview}
aria-label={translate(
'auto.components.PullRequestPage.ae9a38fd4a',
'Remove reviewer {{value0}}',
{ value0: reviewer.login }
)}
onClick={() => {
void handleRemoveReviewers([reviewer.login])
}}
>
<X className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')}
</TooltipContent>
</Tooltip>
) : null}
</div>
)
})}
</div>
) : (
<div className="py-1 text-[12px] text-muted-foreground">
{translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')}
</div>
)}
<section>
<div className="mb-2 flex items-center justify-between text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span>{translate('auto.components.PullRequestPage.00d3be6bcd', 'Reviewers')}</span>
<Popover open={open} onOpenChange={handleReviewerPickerOpenChange}>
<PopoverAnchor asChild>
<Input
ref={reviewerInputRef}
value={reviewerInput}
onChange={(event) => {
setReviewerInput(event.target.value)
if (!open) {
handleReviewerPickerOpenChange(true)
}
}}
<PopoverTrigger asChild>
<button
type="button"
disabled={submitting || !canRequestReview}
placeholder={translate(
'auto.components.PullRequestPage.3bde131f49',
'Type or choose a user'
)}
aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')}
aria-expanded={open}
aria-haspopup="listbox"
className="mt-3 h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs"
onFocus={() => {
if (canRequestReview) {
handleReviewerPickerOpenChange(true)
}
}}
onClick={() => {
if (canRequestReview) {
handleReviewerPickerOpenChange(true)
}
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
event.preventDefault()
setOpen(true)
setActiveReviewerIndex((current) => (current + 1) % actionableReviewerRows.length)
return
}
if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) {
event.preventDefault()
setOpen(true)
setActiveReviewerIndex(
(current) =>
(current - 1 + actionableReviewerRows.length) % actionableReviewerRows.length
)
return
}
if (event.key === 'Enter') {
event.preventDefault()
const activeReviewer = actionableReviewerRows[activeReviewerIndex]
if (activeReviewer) {
void requestReviewer(activeReviewer)
return
}
void handleRequestReview()
return
}
if (event.key === 'Escape') {
event.preventDefault()
handleReviewerPickerOpenChange(false)
}
}}
/>
</PopoverAnchor>
className="rounded p-0.5 text-muted-foreground transition hover:bg-accent hover:text-foreground disabled:opacity-50"
>
{submitting ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<Pencil className="size-3" />
)}
</button>
</PopoverTrigger>
<PopoverContent
className="flex w-[330px] flex-col overflow-hidden rounded-md border-border/70 p-0"
align="start"
side={reviewerPickerSide}
className="flex max-h-[420px] w-[330px] flex-col overflow-hidden rounded-md border-border/70 p-0"
align="end"
side="bottom"
sideOffset={6}
avoidCollisions={false}
style={{
maxHeight: reviewerPickerMaxHeight ? `${reviewerPickerMaxHeight}px` : undefined
}}
onOpenAutoFocus={(event) => {
event.preventDefault()
}}
>
<div className="border-b border-border/70 px-3 py-2">
<div className="text-[13px] font-semibold text-foreground">
{translate(
'auto.components.PullRequestPage.805cb72cd4',
'Request up to 15 reviewers'
<div className="border-b border-border/70 p-2">
<Input
ref={reviewerInputRef}
value={reviewerInput}
onChange={(event) => setReviewerInput(event.target.value)}
disabled={submitting || !canRequestReview}
placeholder={translate(
'auto.components.PullRequestPage.3bde131f49',
'Type or choose a user'
)}
</div>
aria-label={translate('auto.components.PullRequestPage.a04c137bb7', 'Reviewer')}
aria-expanded={open}
aria-haspopup="listbox"
className="h-8 min-w-0 cursor-text rounded-md border-border/50 bg-background text-xs"
onKeyDown={(event) => {
if (event.key === 'ArrowDown' && actionableReviewerRows.length > 0) {
event.preventDefault()
setActiveReviewerIndex(
(current) => (current + 1) % actionableReviewerRows.length
)
return
}
if (event.key === 'ArrowUp' && actionableReviewerRows.length > 0) {
event.preventDefault()
setActiveReviewerIndex(
(current) =>
(current - 1 + actionableReviewerRows.length) %
actionableReviewerRows.length
)
return
}
if (event.key === 'Enter') {
event.preventDefault()
const activeReviewer = actionableReviewerRows[activeReviewerIndex]
if (activeReviewer) {
void requestReviewer(activeReviewer)
return
}
void handleRequestReview()
return
}
if (event.key === 'Escape') {
event.preventDefault()
handleReviewerPickerOpenChange(false)
}
}}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
{reviewerMetadata.loading ? (
@@ -1192,7 +1291,67 @@ function PRReviewersPanel({
</PopoverContent>
</Popover>
</div>
</aside>
{loading && !hasReviewerMetadata ? (
<div className="flex items-center gap-2 py-1 text-[12px] text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')}
</div>
) : reviewers.length > 0 ? (
<div className="flex flex-col gap-2">
{reviewers.map((reviewer) => {
const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase())
return (
<div key={reviewer.login} className="flex min-w-0 items-center gap-2">
<ReviewerAvatar login={reviewer.login} avatarUrl={reviewer.avatarUrl} />
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">
{reviewer.login}
</div>
{reviewer.name ? (
<div className="truncate text-[11px] text-muted-foreground">
{reviewer.name}
</div>
) : null}
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{reviewer.stateLabel}
</span>
{canRemoveReviewer ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
disabled={submitting || !canRequestReview}
aria-label={translate(
'auto.components.PullRequestPage.ae9a38fd4a',
'Remove reviewer {{value0}}',
{ value0: reviewer.login }
)}
onClick={() => {
void handleRemoveReviewers([reviewer.login])
}}
>
<X className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')}
</TooltipContent>
</Tooltip>
) : null}
</div>
)
})}
</div>
) : (
<div className="py-1 text-[12px] text-muted-foreground">
{translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')}
</div>
)}
</section>
)
}
@@ -2968,7 +3127,7 @@ function ConversationTab({
const rightPanel =
item.type === 'pr' ? (
<div className="flex h-fit flex-col gap-3 xl:sticky xl:top-4">
<div className="flex h-fit flex-col gap-5 xl:sticky xl:top-4">
<PRActionsPanel
item={item}
repoPath={repoPath}
@@ -2978,6 +3137,12 @@ function ConversationTab({
onStateChange={onStateChange}
onMutated={onMutated}
/>
<PRAssigneesPanel
item={item}
repoPath={repoPath}
projectOrigin={projectOrigin}
onMutated={onMutated}
/>
<PRReviewersPanel
item={item}
loading={loading}
@@ -5621,7 +5786,7 @@ function GHCommentComposer({
)
return (
<div className={cn('flex flex-col items-start gap-2', className)}>
<div className={cn('relative', className)}>
<MentionTextarea
textareaRef={textareaRef}
value={body}
@@ -5634,21 +5799,29 @@ function GHCommentComposer({
rows={4}
mentionOptions={mentionOptions}
wrapperClassName="flex min-h-20 w-full items-stretch"
className="scrollbar-sleek block h-20 max-h-[240px] min-h-20 w-full resize-none overflow-y-auto rounded-md border border-input bg-card px-3 py-2 text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="scrollbar-sleek block h-20 max-h-[240px] min-h-20 w-full resize-none overflow-y-auto rounded-md border border-input bg-card px-3 py-2 pb-12 pr-12 text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
onClick={handleSubmit}
disabled={!canSubmitComment || submitting}
className="gap-2"
aria-label={translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
>
{submitting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Send className="size-3.5" />
)}
{translate('auto.components.PullRequestPage.3450247584', 'Comment')}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon-sm"
onClick={handleSubmit}
disabled={!canSubmitComment || submitting}
className="absolute bottom-3 right-3 shadow-sm"
aria-label={translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
>
{submitting ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')}
</TooltipContent>
</Tooltip>
</div>
)
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1888,6 +1888,10 @@ function Terminal(): React.JSX.Element | null {
// Keeping `isVisible` true for the portaled tab lets
// xterm fit and stream foreground output in-place.
isVisible={isActiveTerminalTab || isActivityPortalTab}
// Why: inactive tabs in the visible legacy surface
// are tab-hidden, not worktree-hidden, so they need
// the same light resume path as split-group overlays.
isWorktreeActive={isVisible || isActivityPortalTab}
// Why: when portaled to Activity for a specific agent
// pane, isolate that leaf so split siblings stay
// hidden. Workspace renders pass null → no override.
@@ -2,7 +2,16 @@
import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { FileText, Globe, Plus, Server, ServerOff, Smartphone, SquareTerminal } from 'lucide-react'
import {
FileText,
FolderTree,
Globe,
Plus,
Server,
ServerOff,
Smartphone,
SquareTerminal
} from 'lucide-react'
import { useAppStore } from '@/store'
import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors'
import {
@@ -77,6 +86,12 @@ import {
type CmdJActionResult,
type CmdJSettingsResult
} from '@/components/cmd-j/palette-results'
import { buildImportedWorktreesCardCandidates } from '@/components/sidebar/imported-worktrees-card-candidates'
import {
hasCmdJProjectSearchCandidates,
searchCmdJProjectResults,
type CmdJProjectSearchResult
} from '@/components/cmd-j/palette-project-results'
import {
buildCmdJQuickActionContext,
captureCmdJActiveGroupSnapshot,
@@ -139,6 +154,12 @@ type QuickActionPaletteItem = {
result: CmdJActionResult
}
type ProjectTargetPaletteItem = {
id: string
type: 'project-target'
result: CmdJProjectSearchResult
}
type SectionHeader = {
id: string
type: 'section-header'
@@ -160,6 +181,7 @@ type CreateWorktreePaletteItem = {
// Keep future quick actions curated; route one-time setup flows through Settings.
type PaletteItem =
| WorktreePaletteItem
| ProjectTargetPaletteItem
| SettingsPaletteItem
| QuickActionPaletteItem
| BrowserPaletteItem
@@ -305,9 +327,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction)
const revealSidebarRow = useAppStore((s) => s.revealSidebarRow)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const allWorktrees = useAllWorktrees()
const repos = useAppStore((s) => s.repos)
const projectGroups = useAppStore((s) => s.projectGroups)
const projects = useAppStore((s) => s.projects)
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
// Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a
// working agent in a non-focused pane still surface as 'working' in the
@@ -740,6 +768,68 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[settingsSections]
)
const actionResults = useMemo(() => buildCmdJActionResults(getCmdJQuickActions()), [])
// Why: Cmd+J should only offer project jumps the sidebar can actually reveal;
// archived-only repos are intentionally left out of this navigation surface.
const renderableProjectRepoIds = useMemo(() => {
const ids = new Set<string>()
for (const worktree of allWorktrees) {
if (!worktree.isArchived) {
ids.add(worktree.repoId)
}
}
for (const repo of repos) {
if ((worktreesByRepo[repo.id]?.length ?? 0) === 0) {
ids.add(repo.id)
}
}
for (const repoId of buildImportedWorktreesCardCandidates({
repos,
detectedWorktreesByRepo
}).keys()) {
ids.add(repoId)
}
for (const creation of Object.values(pendingWorktreeCreations)) {
ids.add(creation.request.repoId)
}
return ids
}, [allWorktrees, detectedWorktreesByRepo, pendingWorktreeCreations, repos, worktreesByRepo])
const hasAnyProjectSearchCandidates = useMemo(
() =>
hasCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds: renderableProjectRepoIds
}),
[projectGroups, projectHostSetups, projects, renderableProjectRepoIds, repos]
)
const projectTargetItems = useMemo<ProjectTargetPaletteItem[]>(
() =>
hasQuery
? searchCmdJProjectResults({
query: deferredQuery,
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds: renderableProjectRepoIds
}).map((result) => ({
id: result.id,
type: 'project-target' as const,
result
}))
: [],
[
deferredQuery,
hasQuery,
projectGroups,
projectHostSetups,
projects,
renderableProjectRepoIds,
repos
]
)
const prefetchCreateWorkspaceBaseForComposer = useCallback((initialRepoId?: string): void => {
const state = useAppStore.getState()
@@ -830,6 +920,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
// viewport naturally.
const worktreeCap = !hasQuery && openTabItems.length > 0 ? EMPTY_QUERY_WORKTREE_CAP : Infinity
const visibleWorktreeItems = hasQuery ? worktreeItems : worktreeItems.slice(0, worktreeCap)
const visibleProjectTargetItems = hasQuery ? projectTargetItems : []
const visibleMiddleItems = hasQuery ? middleItems : []
const visibleOpenTabItems = hasQuery
? openTabItems
@@ -838,15 +929,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
return {
visibleWorktreeItems,
visibleProjectTargetItems,
visibleMiddleItems,
visibleOpenTabItems,
showWorktreeHint
}
}, [worktreeItems, middleItems, openTabItems, hasQuery])
}, [worktreeItems, projectTargetItems, middleItems, openTabItems, hasQuery])
const selectableItems = useMemo<PaletteItem[]>(
() => [
...paletteSections.visibleWorktreeItems,
...paletteSections.visibleProjectTargetItems,
...paletteSections.visibleMiddleItems,
...paletteSections.visibleOpenTabItems
],
@@ -864,11 +957,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const listEntries = useMemo<PaletteListEntry[]>(() => {
const entries: PaletteListEntry[] = []
const { visibleWorktreeItems, visibleMiddleItems, visibleOpenTabItems, showWorktreeHint } =
paletteSections
const {
visibleWorktreeItems,
visibleProjectTargetItems,
visibleMiddleItems,
visibleOpenTabItems,
showWorktreeHint
} = paletteSections
const visibleWorkspaceItemCount = visibleWorktreeItems.length + (showCreateAction ? 1 : 0)
const populatedSectionCount = [
visibleWorkspaceItemCount,
visibleProjectTargetItems.length,
visibleMiddleItems.length,
visibleOpenTabItems.length
].filter((count) => count > 0).length
@@ -883,6 +982,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const showOpenTabsHeader = hasQuery
? visibleOpenTabItems.length > 0 && populatedSectionCount > 1
: visibleOpenTabItems.length > 0
const showProjectTargetHeader =
hasQuery && visibleProjectTargetItems.length > 0 && populatedSectionCount > 1
const showMiddleHeader = hasQuery && visibleMiddleItems.length > 0 && populatedSectionCount > 1
if (visibleWorkspaceItemCount > 0) {
@@ -899,11 +1000,6 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
})
}
appendPaletteListEntries(entries, visibleWorktreeItems)
if (showCreateAction) {
// Why: the typed create affordance is workspace-scoped, so keep it
// directly under workspace matches instead of after actions/tabs.
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
if (showWorktreeHint) {
entries.push({
id: '__hint_worktree_cap__',
@@ -916,6 +1012,24 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
})
}
}
if (visibleProjectTargetItems.length > 0) {
if (showProjectTargetHeader) {
entries.push({
id: '__header_projects_groups__',
type: 'section-header',
label: translate(
'auto.components.WorktreeJumpPalette.projectsGroupsHeader',
'Projects & Groups'
)
})
}
appendPaletteListEntries(entries, visibleProjectTargetItems)
}
if (showCreateAction) {
// Why: project/group jump targets are navigation results; keep them
// directly after worktree matches before the creation fallback.
entries.push({ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' })
}
if (visibleMiddleItems.length > 0) {
if (showMiddleHeader) {
entries.push({
@@ -1260,10 +1374,41 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
[buildQuickActionContext, closeModal, recordFeatureInteraction]
)
const handleSelectProjectTarget = useCallback(
(result: CmdJProjectSearchResult) => {
skipRestoreFocusRef.current = true
// Why: selecting a project or repo group is a sidebar navigation action;
// it should reveal the grouping row without activating an arbitrary workspace.
revealSidebarRow(result.rowKey, { behavior: 'smooth', highlight: true })
recordFeatureInteraction('cmd-j')
closeModal()
setSelectedItemId('')
if (previousActiveTabTypeRef.current === 'browser' && previousBrowserPageIdRef.current) {
requestBrowserFocus({
pageId: previousBrowserPageIdRef.current,
target: previousBrowserFocusTargetRef.current
})
return
}
if (previousWorktreeIdRef.current) {
focusFallbackSurface()
}
},
[
closeModal,
focusFallbackSurface,
recordFeatureInteraction,
requestBrowserFocus,
revealSidebarRow
]
)
const handleSelectItem = useCallback(
(item: PaletteItem) => {
if (item.type === 'worktree') {
handleSelectWorktree(item.worktree.id)
} else if (item.type === 'project-target') {
handleSelectProjectTarget(item.result)
} else if (item.type === 'browser-page') {
handleSelectBrowserPage(item.result)
} else if (item.type === 'simulator-tab') {
@@ -1278,6 +1423,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
},
[
handleSelectBrowserPage,
handleSelectProjectTarget,
handleSelectQuickAction,
handleSelectSettings,
handleSelectSimulatorTab,
@@ -1495,7 +1641,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const resultCount = selectableItems.length
const emptyState = (() => {
if ((hasAnySearchableWorktrees || hasAnyMiddleResults || hasAnyOpenTabs) && hasQuery) {
if (
(hasAnySearchableWorktrees ||
hasAnyProjectSearchCandidates ||
hasAnyMiddleResults ||
hasAnyOpenTabs) &&
hasQuery
) {
return {
title: translate(
'auto.components.WorktreeJumpPalette.dbd9d87eec',
@@ -1503,7 +1655,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
),
subtitle: translate(
'auto.components.WorktreeJumpPalette.c4afa68159',
'Try a worktree, setting, action, tab title, agent prompt, URL, PR, or port.'
'Try a worktree, project, setting, action, tab title, agent prompt, URL, PR, or port.'
)
}
}
@@ -1698,7 +1850,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
)}
</span>
)}
<span className="truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">
<span className="truncate text-[14px] font-semibold text-foreground">
{entry.match.displayNameRange ? (
<HighlightedText
text={worktree.displayName}
@@ -1776,6 +1928,53 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
)
}
if (entry.type === 'project-target') {
const result = entry.result
const isProject = result.kind === 'project'
const hostBadge = isProject ? getPaletteHostBadge(result.repo, hostOptions) : null
const badgeLabel = isProject
? translate('auto.components.WorktreeJumpPalette.projectBadge', 'Project')
: translate('auto.components.WorktreeJumpPalette.repoGroupBadge', 'Repo group')
return (
<CommandItem
key={entry.id}
value={entry.id}
onSelect={() => handleSelectItem(entry)}
className={cn(
'group mx-0.5 flex cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]',
'data-[selected=true]:border-border data-[selected=true]:bg-accent data-[selected=true]:text-foreground'
)}
>
<div className="flex w-4 shrink-0 items-center justify-center self-start pt-0.5 text-muted-foreground/85">
<FolderTree className="size-3.5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[14px] font-semibold text-foreground">
{result.title}
</span>
<span className="shrink-0 rounded-[6px] border border-border/60 bg-background/45 px-1.5 py-px text-[9px] font-medium leading-normal text-muted-foreground/88">
{badgeLabel}
</span>
</div>
</div>
{isProject ? (
<div className="flex shrink-0 items-center gap-1.5">
<PaletteHostBadgeChip badge={hostBadge} />
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={result.repo.badgeColor} />
<span className="truncate">{result.repo.displayName}</span>
</span>
</div>
) : null}
</div>
</div>
</CommandItem>
)
}
if (entry.type === 'settings' || entry.type === 'quick-action') {
const result = entry.result
const Icon = result.icon
@@ -13,6 +13,9 @@ import {
ACTIVITY_SEARCH_QUERY_MAX_BYTES,
activityThreadResponseRenderPreview,
activityThreadMatchesSearchQuery,
handleActivityFilterFocusShortcut,
isActivityFilterFocusShortcut,
shouldIgnoreActivityFilterFocusShortcutTarget,
buildActivityThreadGroups,
buildActivityEvents,
buildAgentPaneThreads,
@@ -724,3 +727,188 @@ describe('activity thread grouping', () => {
expect(buildActivityThreadGroups([], 'status')).toEqual([])
})
})
describe('activity filter focus shortcut', () => {
it('matches Cmd+F on Mac and Ctrl+F elsewhere without extra modifiers', () => {
expect(
isActivityFilterFocusShortcut(
{ key: 'f', metaKey: true, ctrlKey: false, shiftKey: false, altKey: false },
true
)
).toBe(true)
expect(
isActivityFilterFocusShortcut(
{ key: 'F', metaKey: false, ctrlKey: true, shiftKey: false, altKey: false },
false
)
).toBe(true)
expect(
isActivityFilterFocusShortcut(
{ key: 'f', metaKey: false, ctrlKey: true, shiftKey: false, altKey: false },
true
)
).toBe(false)
expect(
isActivityFilterFocusShortcut(
{ key: 'f', metaKey: true, ctrlKey: false, shiftKey: true, altKey: false },
true
)
).toBe(false)
expect(
isActivityFilterFocusShortcut(
{ key: 'f', metaKey: true, ctrlKey: true, shiftKey: false, altKey: false },
true
)
).toBe(false)
expect(
isActivityFilterFocusShortcut(
{ key: 'f', metaKey: true, ctrlKey: true, shiftKey: false, altKey: false },
false
)
).toBe(false)
})
it('prevents default, focuses, and selects only for handled filter shortcuts', () => {
let prevented = 0
let stopped = 0
let stoppedImmediate = 0
let focused = 0
let selected = 0
const input = {
focus: () => {
focused += 1
},
select: () => {
selected += 1
}
} as Pick<HTMLInputElement, 'focus' | 'select'>
const activeElement = {
classList: { contains: () => false }
} as unknown as Element
const terminalElement = {
classList: { contains: (className: string) => className === 'xterm-helper-textarea' }
} as unknown as Element
const terminalPortalTarget = {
contains: (target: Element) => target === terminalElement
} as unknown as HTMLElement
expect(
handleActivityFilterFocusShortcut({
activeElement,
event: {
key: 'f',
metaKey: true,
ctrlKey: false,
shiftKey: false,
altKey: false,
preventDefault: () => {
prevented += 1
},
stopPropagation: () => {
stopped += 1
},
stopImmediatePropagation: () => {
stoppedImmediate += 1
}
},
input,
isMac: true,
terminalPortalTargets: []
})
).toBe(true)
expect(prevented).toBe(1)
expect(stopped).toBe(1)
expect(stoppedImmediate).toBe(1)
expect(focused).toBe(1)
expect(selected).toBe(1)
expect(
handleActivityFilterFocusShortcut({
activeElement: terminalElement,
event: {
key: 'f',
metaKey: true,
ctrlKey: false,
shiftKey: false,
altKey: false,
preventDefault: () => {
prevented += 1
},
stopPropagation: () => {
stopped += 1
},
stopImmediatePropagation: () => {
stoppedImmediate += 1
}
},
input,
isMac: true,
terminalPortalTargets: [terminalPortalTarget]
})
).toBe(false)
expect(prevented).toBe(1)
expect(stopped).toBe(1)
expect(stoppedImmediate).toBe(1)
expect(focused).toBe(1)
expect(selected).toBe(1)
})
it('does not prevent default when the filter input is unavailable', () => {
let prevented = 0
const activeElement = {
classList: { contains: () => false }
} as unknown as Element
expect(
handleActivityFilterFocusShortcut({
activeElement,
event: {
key: 'f',
metaKey: true,
ctrlKey: false,
shiftKey: false,
altKey: false,
preventDefault: () => {
prevented += 1
},
stopPropagation: () => {
throw new Error('unavailable input must not stop propagation')
},
stopImmediatePropagation: () => {
throw new Error('unavailable input must not stop immediate propagation')
}
},
input: null,
isMac: true,
terminalPortalTargets: []
})
).toBe(false)
expect(prevented).toBe(0)
})
it('ignores shortcut handling while terminal-owned elements have focus', () => {
const terminalTextarea = {
classList: { contains: (className: string) => className === 'xterm-helper-textarea' }
} as unknown as Element
const portalChild = {
classList: { contains: () => false }
} as unknown as Element
const outside = {
classList: { contains: () => false }
} as unknown as Element
const portalTarget = {
contains: (target: Element) => target === portalChild || target === terminalTextarea
} as unknown as HTMLElement
const hiddenWorkbenchTerminal = {
classList: { contains: (className: string) => className === 'xterm-helper-textarea' }
} as unknown as Element
expect(shouldIgnoreActivityFilterFocusShortcutTarget(terminalTextarea, [portalTarget])).toBe(
true
)
expect(shouldIgnoreActivityFilterFocusShortcutTarget(portalChild, [portalTarget])).toBe(true)
expect(shouldIgnoreActivityFilterFocusShortcutTarget(outside, [portalTarget])).toBe(false)
expect(
shouldIgnoreActivityFilterFocusShortcutTarget(hiddenWorkbenchTerminal, [portalTarget])
).toBe(false)
})
})
@@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: this prototype keeps the real-data adapter
and current visual skeleton together until the next refinement pass decides
which pieces become production modules. */
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
Bell,
@@ -1028,6 +1028,70 @@ export function activityThreadMatchesSearchQuery({
return threadSearchText(thread).includes(trimmedQuery.toLowerCase())
}
export function isActivityFilterFocusShortcut(
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
isMac = navigator.userAgent.includes('Mac')
): boolean {
if (event.key.toLowerCase() !== 'f' || event.shiftKey || event.altKey) {
return false
}
return isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
}
export function shouldIgnoreActivityFilterFocusShortcutTarget(
target: Element | null,
terminalPortalTargets: (HTMLElement | null)[]
): boolean {
if (!target) {
return false
}
// Why: the workspace terminal stays mounted while Activity is open; only the
// Activity-portaled terminal should keep Cmd/Ctrl+F for terminal search.
return terminalPortalTargets.some((portalTarget) => portalTarget?.contains(target) ?? false)
}
export function handleActivityFilterFocusShortcut({
activeElement,
event,
input,
isMac,
terminalPortalTargets
}: {
activeElement: Element | null
event: Pick<
KeyboardEvent,
| 'altKey'
| 'ctrlKey'
| 'key'
| 'metaKey'
| 'preventDefault'
| 'shiftKey'
| 'stopImmediatePropagation'
| 'stopPropagation'
>
input: Pick<HTMLInputElement, 'focus' | 'select'> | null
isMac?: boolean
terminalPortalTargets: (HTMLElement | null)[]
}): boolean {
if (shouldIgnoreActivityFilterFocusShortcutTarget(activeElement, terminalPortalTargets)) {
return false
}
if (!isActivityFilterFocusShortcut(event, isMac)) {
return false
}
if (!input) {
return false
}
event.preventDefault()
// Why: hidden workspace xterms can retain focus behind Activity; capture-phase
// handling must stop the chord before xterm forwards it to a local/SSH PTY.
event.stopPropagation()
event.stopImmediatePropagation()
input.focus()
input.select()
return true
}
function ThreadAgentStateIndicator({ thread }: { thread: AgentPaneThread }): React.JSX.Element {
const state = threadAgentState(thread)
const label = threadAgentStateLabel(thread)
@@ -1283,6 +1347,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
const [readFilter, setReadFilter] = useState<ThreadReadFilter>('all')
const [groupBy, setGroupBy] = useState<ActivityGroupBy>('status')
const [query, setQuery] = useState('')
const activityFilterInputRef = useRef<HTMLInputElement | null>(null)
const [compactMode, setCompactMode] = useState(false)
const [selectedPaneKey, setSelectedPaneKey] = useState<string | null>(null)
const [displayedPaneKey, setDisplayedPaneKey] = useState<string | null>(null)
@@ -1555,6 +1620,20 @@ export default function ActivityPrototypePage(): React.JSX.Element {
}
}, [])
useEffect(() => {
const focusActivityFilter = (event: KeyboardEvent): void => {
handleActivityFilterFocusShortcut({
activeElement: document.activeElement,
event,
input: activityFilterInputRef.current,
terminalPortalTargets: [activePortalTargetEl, inactivePortalTargetEl]
})
}
window.addEventListener('keydown', focusActivityFilter, { capture: true })
return () => window.removeEventListener('keydown', focusActivityFilter, { capture: true })
}, [activePortalTargetEl, inactivePortalTargetEl])
const markThreadRead = (thread: AgentPaneThread): void => {
storeData.acknowledgeAgents([thread.paneKey])
}
@@ -1660,6 +1739,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={activityFilterInputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={translate(
@@ -0,0 +1,262 @@
import { isCmdJPaletteQueryTooLarge } from './palette-results'
import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import {
getProjectGroupHeaderKey,
getProjectHeaderRevealTarget,
type ProjectGroupingModel
} from '../sidebar/worktree-list-groups'
export type CmdJProjectGroupResult = {
id: string
kind: 'project-group'
title: string
description: string
rowKey: string
order: number
keywords: string[]
}
export type CmdJProjectResult = {
id: string
kind: 'project'
title: string
description: string
rowKey: string
repo: Repo
order: number
keywords: string[]
}
export type CmdJProjectSearchResult = CmdJProjectGroupResult | CmdJProjectResult
type RankedProjectResult = {
result: CmdJProjectSearchResult
rule: number
score: number
}
const PROJECT_GROUP_ALIASES = ['group', 'repo group']
const PROJECT_ALIASES = ['project', 'repo']
function normalizeQuery(value: string): string {
let normalized = ''
let pendingWhitespace = false
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (isCmdJPaletteWhitespace(code)) {
pendingWhitespace = normalized.length > 0
continue
}
if (pendingWhitespace) {
normalized += ' '
pendingWhitespace = false
}
normalized += value.charAt(index).toLowerCase()
}
return normalized
}
function isCmdJPaletteWhitespace(code: number): boolean {
return (
code === 32 ||
(code >= 9 && code <= 13) ||
code === 160 ||
code === 5760 ||
(code >= 8192 && code <= 8202) ||
code === 8232 ||
code === 8233 ||
code === 8239 ||
code === 8287 ||
code === 12288 ||
code === 65279
)
}
function uniqueNormalized(values: readonly string[]): string[] {
return [...new Set(values.map(normalizeQuery).filter(Boolean))]
}
function tokenize(value: string): string[] {
return normalizeQuery(value)
.split(/[^a-z0-9]+/)
.filter(Boolean)
}
function tokenScore(query: string, values: readonly string[]): number {
const candidateTokens = values.flatMap(tokenize)
if (candidateTokens.length === 0) {
return 0
}
let score = 0
for (const queryToken of tokenize(query)) {
let best = 0
for (const candidateToken of candidateTokens) {
if (candidateToken === queryToken) {
best = Math.max(best, 3)
} else if (candidateToken.startsWith(queryToken)) {
best = Math.max(best, 2)
} else if (candidateToken.includes(queryToken)) {
best = Math.max(best, 1)
}
}
score += best
}
return score
}
function buildCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
}: {
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
projects: readonly Project[]
projectHostSetups: readonly ProjectHostSetup[]
renderableRepoIds?: ReadonlySet<string>
}): CmdJProjectSearchResult[] {
const projectGrouping: ProjectGroupingModel = { projects, projectHostSetups }
const repoMap = new Map(repos.map((repo) => [repo.id, repo]))
const candidates: CmdJProjectSearchResult[] = []
projectGroups.forEach((group, order) => {
candidates.push({
id: `project-group:${group.id}`,
kind: 'project-group',
title: group.name,
description: translate(
'auto.components.cmd.j.palette.project.results.repoGroup',
'Repo group'
),
rowKey: getProjectGroupHeaderKey(group.id),
order,
keywords: uniqueNormalized([group.name, ...PROJECT_GROUP_ALIASES])
})
})
const seenRowKeys = new Set<string>()
repos.forEach((repo, repoIndex) => {
if (renderableRepoIds && !renderableRepoIds.has(repo.id)) {
return
}
const target = getProjectHeaderRevealTarget(repo.id, repoMap, projectGrouping)
if (!target.repo || seenRowKeys.has(target.key)) {
return
}
seenRowKeys.add(target.key)
candidates.push({
id: `project:${target.key}`,
kind: 'project',
title: target.label,
description: translate('auto.components.cmd.j.palette.project.results.project', 'Project'),
rowKey: target.key,
repo: target.repo,
order: projectGroups.length + repoIndex,
keywords: uniqueNormalized([target.label, repo.displayName, ...PROJECT_ALIASES])
})
})
return candidates
}
export function hasCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
}: {
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
projects: readonly Project[]
projectHostSetups: readonly ProjectHostSetup[]
renderableRepoIds?: ReadonlySet<string>
}): boolean {
return (
buildCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
}).length > 0
)
}
function projectRankingForCandidate(
query: string,
candidate: CmdJProjectSearchResult
): RankedProjectResult | null {
const title = normalizeQuery(candidate.title)
if (query === title) {
return { result: candidate, rule: 1, score: 0 }
}
if (title.startsWith(query)) {
return { result: candidate, rule: 2, score: 0 }
}
const aliasKeywords = candidate.kind === 'project-group' ? PROJECT_GROUP_ALIASES : PROJECT_ALIASES
if (aliasKeywords.map(normalizeQuery).includes(query)) {
return { result: candidate, rule: 3, score: 0 }
}
if (candidate.keywords.some((keyword) => keyword.startsWith(query))) {
return { result: candidate, rule: 4, score: 0 }
}
const score = tokenScore(query, [candidate.title, ...candidate.keywords])
return score > 0 ? { result: candidate, rule: 5, score } : null
}
function compareProjectRanked(a: RankedProjectResult, b: RankedProjectResult): number {
if (a.rule !== b.rule) {
return a.rule - b.rule
}
if (a.score !== b.score) {
return b.score - a.score
}
if (a.result.order !== b.result.order) {
return a.result.order - b.result.order
}
return a.result.id.localeCompare(b.result.id)
}
export function searchCmdJProjectResults({
query,
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
}: {
query: string
projectGroups: readonly ProjectGroup[]
repos: readonly Repo[]
projects: readonly Project[]
projectHostSetups: readonly ProjectHostSetup[]
renderableRepoIds?: ReadonlySet<string>
}): CmdJProjectSearchResult[] {
// Why: oversized pasted input should not force the palette to scan project,
// repo, or group names that may include private workspace details.
if (isCmdJPaletteQueryTooLarge(query)) {
return []
}
const normalizedQuery = normalizeQuery(query)
// Why: project/group rows sit after worktree matches, so one-character
// searches would add broad noisy navigation targets before intent is clear.
if (normalizedQuery.length < 2) {
return []
}
return buildCmdJProjectSearchCandidates({
projectGroups,
repos,
projects,
projectHostSetups,
renderableRepoIds
})
.map((candidate) => projectRankingForCandidate(normalizedQuery, candidate))
.filter((entry): entry is RankedProjectResult => entry !== null)
.sort(compareProjectRanked)
.map((entry) => entry.result)
}
@@ -1,3 +1,4 @@
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Globe, Settings } from 'lucide-react'
import type { CmdJQuickAction } from './quick-actions'
@@ -10,7 +11,9 @@ import {
type CmdJActionResult,
type CmdJSettingsResult
} from './palette-results'
import { hasCmdJProjectSearchCandidates, searchCmdJProjectResults } from './palette-project-results'
import type { SettingsNavSection } from '@/lib/settings-navigation-types'
import type { Project, ProjectGroup, ProjectHostSetup, Repo } from '../../../../shared/types'
const noopRun: CmdJQuickAction['run'] = async () => ({ status: 'ok' })
const available: CmdJQuickAction['isAvailable'] = () => ({ available: true })
@@ -259,3 +262,191 @@ describe('Cmd+J palette middle-band ranking', () => {
).toEqual([])
})
})
function repo(id: string, displayName: string, projectGroupId?: string | null): Repo {
return {
id,
path: path.join('/repos', displayName),
displayName,
badgeColor: '#999999',
addedAt: 1,
projectGroupId
} as Repo
}
function project(id: string, displayName: string): Project {
return {
id,
displayName,
badgeColor: '#999999',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
}
}
function setup(id: string, projectId: string, hostId: string, repoId: string): ProjectHostSetup {
return {
id,
projectId,
hostId: hostId as ProjectHostSetup['hostId'],
repoId,
path: path.join('/repos', repoId),
displayName: repoId,
setupState: 'ready',
setupMethod: 'cloned',
createdAt: 1,
updatedAt: 1
}
}
function projectGroup(id: string, name: string, parentGroupId: string | null = null): ProjectGroup {
return {
id,
name,
parentPath: null,
parentGroupId,
createdFrom: 'manual',
tabOrder: 1,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
}
describe('Cmd+J project and repo-group search', () => {
it('finds a Project Group by name', () => {
const [result] = searchCmdJProjectResults({
query: 'infra',
projectGroups: [projectGroup('group-1', 'Infrastructure')],
repos: [],
projects: [],
projectHostSetups: []
})
expect(result).toMatchObject({
kind: 'project-group',
title: 'Infrastructure',
description: 'Repo group',
rowKey: 'project-group:group-1'
})
})
it('finds a Project by project name and a repo-backed fallback by repo name', () => {
const projectResults = searchCmdJProjectResults({
query: 'api',
projectGroups: [],
repos: [repo('repo-1', 'legacy-api'), repo('repo-2', 'source-folder')],
projects: [project('project-1', 'API Service')],
projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')]
})
const fallbackResults = searchCmdJProjectResults({
query: 'source',
projectGroups: [],
repos: [repo('repo-1', 'legacy-api'), repo('repo-2', 'source-folder')],
projects: [project('project-1', 'API Service')],
projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')]
})
expect(projectResults.map((result) => [result.title, result.rowKey])).toEqual([
['API Service', 'project:project-1']
])
expect(fallbackResults.map((result) => [result.title, result.rowKey])).toEqual([
['source-folder', 'repo:repo-2']
])
})
it('uses setup-specific project header keys for multi-setup projects on one host', () => {
const results = searchCmdJProjectResults({
query: 'platform',
projectGroups: [],
repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-b')],
projects: [project('project-1', 'Platform')],
projectHostSetups: [
setup('setup-1', 'project-1', 'local', 'repo-1'),
setup('setup-2', 'project-1', 'local', 'repo-2')
]
})
expect(results.map((result) => result.rowKey)).toEqual([
'project:project-1::setup:repo-1',
'project:project-1::setup:repo-2'
])
})
it('suppresses raw Project records without renderable repo header targets', () => {
const results = searchCmdJProjectResults({
query: 'orphan',
projectGroups: [],
repos: [],
projects: [project('project-1', 'Orphan Project')],
projectHostSetups: []
})
expect(results).toEqual([])
})
it('suppresses repo-backed projects when the sidebar cannot render their header row', () => {
const results = searchCmdJProjectResults({
query: 'archived',
projectGroups: [],
repos: [repo('repo-1', 'archived-service')],
projects: [project('project-1', 'Archived Service')],
projectHostSetups: [setup('setup-1', 'project-1', 'local', 'repo-1')],
renderableRepoIds: new Set()
})
expect(results).toEqual([])
})
it('reports searchable project candidates even when a query has no match', () => {
expect(
hasCmdJProjectSearchCandidates({
projectGroups: [projectGroup('group-1', 'Infrastructure')],
repos: [],
projects: [],
projectHostSetups: []
})
).toBe(true)
expect(
searchCmdJProjectResults({
query: 'zzzz',
projectGroups: [projectGroup('group-1', 'Infrastructure')],
repos: [],
projects: [],
projectHostSetups: []
})
).toEqual([])
})
it('rejects oversized project queries before reading names', () => {
const oversizedQuery = 'secret-palette-query'.repeat(CMD_J_PALETTE_QUERY_MAX_BYTES)
const throwingGroup = {
get id() {
throw new Error('oversized palette queries must not scan project groups')
},
get name() {
throw new Error('oversized palette queries must not scan project groups')
}
} as unknown as ProjectGroup
const throwingRepo = {
get id() {
throw new Error('oversized palette queries must not scan repos')
},
get displayName() {
throw new Error('oversized palette queries must not scan repos')
}
} as unknown as Repo
expect(
searchCmdJProjectResults({
query: oversizedQuery,
projectGroups: [throwingGroup],
repos: [throwingRepo],
projects: [],
projectHostSetups: []
})
).toEqual([])
})
})
@@ -34,6 +34,10 @@ type Props = {
onSubmit: (body: string) => Promise<void>
}
function hasDraftText(body: string): boolean {
return /\S/u.test(body)
}
export function DiffCommentPopover({
lineNumber,
startLine,
@@ -48,6 +52,12 @@ export function DiffCommentPopover({
onSubmit
}: Props): React.JSX.Element {
const [body, setBody] = useState('')
// Why: mirror the draft into a ref so the document mousedown listener (empty
// dep array, see below) can read the freshest body without re-registering on
// every keystroke. Used to keep the popover open on outside-click when the
// user has typed something, so an accidental click never discards a draft.
const bodyRef = useRef(body)
bodyRef.current = body
// Why: `submitting` prevents duplicate note rows when the user
// double-clicks the Add note button or hits Enter twice before the
// IPC round-trip resolves. Iteration 1 made submission async and keeps the
@@ -144,6 +154,11 @@ export function DiffCommentPopover({
if (popoverRef.current.contains(ev.target as Node)) {
return
}
// Why: outside-click is a soft dismiss, so preserve any non-whitespace
// draft even when submit's bounded scanner would reject it as too large.
if (hasDraftText(bodyRef.current)) {
return
}
// Why: read the latest onCancel from the ref rather than closing over it
// so the listener does not need to be re-registered on every parent
// render (see onCancelRef comment above).
@@ -0,0 +1,125 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES } from '@/lib/comment-body-submit-state'
import { DiffCommentPopover } from './DiffCommentPopover'
const roots: Root[] = []
async function renderPopover(args: {
onCancel: () => void
onSubmit?: (body: string) => Promise<void>
}): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<DiffCommentPopover
lineNumber={3}
top={10}
onCancel={args.onCancel}
onSubmit={args.onSubmit ?? (async () => {})}
/>
)
})
return container
}
// Why: the textarea is a controlled React input, so writing `.value` directly is
// invisible to React. Drive the change through the native value setter + an
// 'input' event the way React's synthetic onChange listens for it.
function typeInDraft(container: HTMLElement, value: string): void {
const textarea = container.querySelector('textarea')
if (!textarea) {
throw new Error('textarea not found')
}
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
setter?.call(textarea, value)
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
async function clickOutside(): Promise<void> {
await act(async () => {
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
}
describe('DiffCommentPopover outside-click draft preservation', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
roots.splice(0).forEach((root) => {
act(() => root.unmount())
})
document.body.replaceChildren()
vi.clearAllMocks()
})
it('keeps the popover open on outside-click when the draft has content', async () => {
const onCancel = vi.fn()
const container = await renderPopover({ onCancel })
await act(async () => {
typeInDraft(container, 'unsaved note')
})
await clickOutside()
expect(onCancel).not.toHaveBeenCalled()
})
it('dismisses on outside-click when the draft is empty', async () => {
const onCancel = vi.fn()
await renderPopover({ onCancel })
await clickOutside()
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('dismisses on outside-click when the draft is only whitespace', async () => {
const onCancel = vi.fn()
const container = await renderPopover({ onCancel })
await act(async () => {
typeInDraft(container, ' ')
})
await clickOutside()
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('keeps the popover open when non-whitespace text follows large leading whitespace', async () => {
const onCancel = vi.fn()
const container = await renderPopover({ onCancel })
await act(async () => {
typeInDraft(container, `${' '.repeat(COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES + 1)}note`)
})
await clickOutside()
expect(onCancel).not.toHaveBeenCalled()
})
it('still dismisses after the user clears a draft back to empty', async () => {
const onCancel = vi.fn()
const container = await renderPopover({ onCancel })
await act(async () => {
typeInDraft(container, 'typed then deleted')
})
await act(async () => {
typeInDraft(container, '')
})
await clickOutside()
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
@@ -41,10 +41,25 @@ describe('CombinedDiffFileTree navigation mapping', () => {
it('maps branch and commit entries to combined section prefixes', () => {
const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'renamed' }
expect(getCombinedDiffFileTreeSectionKey('all', entry)).toBe('combined-branch:src/view.ts')
expect(getCombinedDiffFileTreeSectionKey('branch', entry)).toBe('combined-branch:src/view.ts')
expect(getCombinedDiffFileTreeSectionKey('commit', entry)).toBe('combined-commit:src/view.ts')
})
it('keeps all-changes local and branch entries separate', () => {
const localEntry: GitStatusEntry = {
path: 'src/view.ts',
status: 'modified',
area: 'unstaged'
}
const branchEntry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' }
expect(getCombinedDiffFileTreeSectionKey('all', localEntry)).toBe('unstaged:src/view.ts')
expect(getCombinedDiffFileTreeSectionKey('all', branchEntry)).toBe(
'combined-branch:src/view.ts'
)
})
it('expands a collapsed target section and scrolls to its index', () => {
const entry: GitBranchChangeEntry = { path: 'src/view.ts', status: 'modified' }
const toggleSection = vi.fn()
@@ -61,14 +61,14 @@ function buildUncommittedRows(
}
function buildBranchRows(
mode: Extract<CombinedDiffFileTreeMode, 'branch' | 'commit'>,
mode: Extract<CombinedDiffFileTreeMode, 'all' | 'branch' | 'commit'>,
entries: readonly CombinedDiffFileTreeEntry[],
collapsedDirectoryKeys: ReadonlySet<string>
): CombinedDiffTreeNode[] {
const branchEntries = entries.filter(
(entry): entry is GitBranchChangeEntry => !isGitStatusEntry(entry)
)
const area: CombinedDiffBranchTreeArea = mode === 'branch' ? 'combined-branch' : 'combined-commit'
const area: CombinedDiffBranchTreeArea = mode === 'commit' ? 'combined-commit' : 'combined-branch'
const roots = compactSourceControlTree(buildSourceControlTree(area, branchEntries))
return flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[]
}
@@ -149,12 +149,14 @@ export function CombinedDiffFileTree({
const uncommittedGroups = React.useMemo(
() =>
mode === 'uncommitted' ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) : [],
mode === 'all' || mode === 'uncommitted'
? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys)
: [],
[collapsedDirectoryKeys, filteredEntries, mode]
)
const branchRows = React.useMemo(
() =>
mode === 'branch' || mode === 'commit'
mode === 'all' || mode === 'branch' || mode === 'commit'
? buildBranchRows(mode, filteredEntries, collapsedDirectoryKeys)
: [],
[collapsedDirectoryKeys, filteredEntries, mode]
@@ -280,27 +282,52 @@ export function CombinedDiffFileTree({
'No files match the current filters.'
)}
</div>
) : mode === 'uncommitted' ? (
uncommittedGroups.map((group) => (
<div key={group.area} className="py-1">
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{group.label}
) : mode === 'all' || mode === 'uncommitted' ? (
<>
{uncommittedGroups.map((group) => (
<div key={group.area} className="py-1">
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{group.label}
</div>
{group.rows.map((node) => (
<CombinedDiffFileTreeRow
key={node.key}
node={node}
mode={mode}
worktreePath={worktreePath}
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
onToggleDirectory={toggleDirectory}
onNavigate={onNavigate}
/>
))}
</div>
{group.rows.map((node) => (
<CombinedDiffFileTreeRow
key={node.key}
node={node}
mode={mode}
worktreePath={worktreePath}
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
onToggleDirectory={toggleDirectory}
onNavigate={onNavigate}
/>
))}
</div>
))
))}
{mode === 'all' && branchRows.length > 0 ? (
<div className="py-1">
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{translate(
'auto.components.editor.CombinedDiffFileTree.39b6b9e4e4',
'Committed on Branch'
)}
</div>
{branchRows.map((node) => (
<CombinedDiffFileTreeRow
key={node.key}
node={node}
mode={mode}
worktreePath={worktreePath}
activeSectionKey={activeSectionKey}
sectionIndexByKey={sectionIndexByKey}
isCollapsed={collapsedDirectoryKeys.has(node.key)}
onToggleDirectory={toggleDirectory}
onNavigate={onNavigate}
/>
))}
</div>
) : null}
</>
) : (
branchRows.map((node) => (
<CombinedDiffFileTreeRow
@@ -8,6 +8,11 @@ import React, { useState, useEffect, useCallback, useRef, useLayoutEffect } from
import { useVirtualizer } from '@tanstack/react-virtual'
import type { editor as monacoEditor } from 'monaco-editor'
import { useAppStore } from '@/store'
import {
useVirtualizedScrollAnchor,
type VirtualizedScrollAnchor
} from '@/hooks/useVirtualizedScrollAnchor'
import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll-anchor-recording'
import { joinPath } from '@/lib/path'
import { detectLanguage } from '@/lib/language-detect'
import { setWithLRU } from '@/lib/scroll-cache'
@@ -50,7 +55,10 @@ import {
createCombinedDiffSectionIndexMap,
handleCombinedDiffFileTreeNavigation
} from './CombinedDiffFileTree'
import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model'
import {
getCombinedDiffFileTreeSectionKey,
type CombinedDiffFileTreeMode
} from './combined-diff-file-tree-model'
import {
ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT,
type EditorPathMutationTarget
@@ -92,6 +100,7 @@ type CombinedDiffScrollThumb = {
const combinedDiffViewStateCache = new Map<string, CachedCombinedDiffViewState>()
const combinedDiffScrollTopCache = new Map<string, number>()
const combinedDiffScrollAnchorCache = new Map<string, VirtualizedScrollAnchor>()
function buildCombinedGitStatusSignature(
sections: readonly { path: string }[],
@@ -183,6 +192,26 @@ function getInitialCombinedDiffFileTreeCollapsed(
return combinedDiffFileTreeCollapsedPreference ?? combinedDiffFileTreeVisibleByDefault !== true
}
function cachedCombinedDiffSectionsMatchEntries({
entries,
sections,
treeMode
}: {
entries: readonly (GitStatusEntry | GitBranchChangeEntry)[]
sections: readonly DiffSection[]
treeMode: CombinedDiffFileTreeMode
}): boolean {
return (
sections.length === entries.length &&
sections.every((section, index) => {
const entry = entries[index]
return (
entry !== undefined && section.key === getCombinedDiffFileTreeSectionKey(treeMode, entry)
)
})
)
}
export default function CombinedDiffViewer({
file,
viewStateKey
@@ -259,7 +288,14 @@ export default function CombinedDiffViewer({
top: 0,
height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT
})
const pendingRestoreScrollTopRef = useRef<number | null>(null)
const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0)
const scrollAnchorRef = useRef<VirtualizedScrollAnchor>(
combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
)
const latestDomScrollAnchorRef = useRef<VirtualizedScrollAnchor>(
combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
)
const directScrollInputUntilRef = useRef(0)
const activeScrollbarDragCleanupRef = useRef<CombinedDiffScrollbarDragCleanup | null>(null)
const loadedIndicesRef = useRef<Set<number>>(new Set())
const loadingIndicesRef = useRef<Set<number>>(new Set())
@@ -295,6 +331,15 @@ export default function CombinedDiffViewer({
setScrollThumb({ visible: true, top, height })
}, [])
const markDirectScrollInput = useCallback((): void => {
directScrollInputUntilRef.current = window.performance.now() + 250
}, [])
const hasDirectScrollInput = useCallback(
() => window.performance.now() < directScrollInputUntilRef.current,
[]
)
const clearNotesCopiedResetTimer = useCallback((): void => {
if (notesCopiedResetTimerRef.current !== null) {
window.clearTimeout(notesCopiedResetTimerRef.current)
@@ -361,6 +406,7 @@ export default function CombinedDiffViewer({
const isBranchMode = file.diffSource === 'combined-branch'
const isCommitMode = file.diffSource === 'combined-commit'
const isAllMode = file.diffSource === 'combined-all'
const branchCompare =
file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase
? file.branchCompare
@@ -385,12 +431,32 @@ export default function CombinedDiffViewer({
const branchEntries = React.useMemo<GitBranchChangeEntry[]>(() => {
return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries)
}, [file.branchEntriesSnapshot, liveBranchEntries])
const renderableBranchEntries = React.useMemo(
() => (branchCompare ? branchEntries : []),
[branchCompare, branchEntries]
)
const commitEntries = React.useMemo<GitBranchChangeEntry[]>(
() => file.commitEntriesSnapshot ?? [],
[file.commitEntriesSnapshot]
)
const entries = isBranchMode ? branchEntries : isCommitMode ? commitEntries : uncommittedEntries
const treeMode = isBranchMode ? 'branch' : isCommitMode ? 'commit' : 'uncommitted'
const allEntries = React.useMemo(
() => [...uncommittedEntries, ...renderableBranchEntries],
[renderableBranchEntries, uncommittedEntries]
)
const entries = isAllMode
? allEntries
: isBranchMode
? renderableBranchEntries
: isCommitMode
? commitEntries
: uncommittedEntries
const treeMode = isAllMode
? 'all'
: isBranchMode
? 'branch'
: isCommitMode
? 'commit'
: 'uncommitted'
const hasUncommittedEntriesSnapshot = file.uncommittedEntriesSnapshot !== undefined
const shouldAutoReloadFromGitStatus = shouldAutoReloadCombinedDiffFromGitStatus({
mode: treeMode,
@@ -443,15 +509,23 @@ export default function CombinedDiffViewer({
// Why: switching tabs or worktrees unmounts this viewer through the shared
// editor surface above it. Cache the rendered combined-diff state by the
// visible pane key so remounting can restore loaded sections and scroll
// position instead of flashing back to "Loading..." and forcing the user to
// find their place again.
useEffect(() => {
// position before the remounted surface paints at the top.
useLayoutEffect(() => {
const cached = combinedDiffViewStateCache.get(viewStateKey)
const canRestoreSnapshotSectionsByKey =
hasUncommittedEntriesSnapshot &&
cached !== undefined &&
cachedCombinedDiffSectionsMatchEntries({
entries,
sections: cached.sections,
treeMode
})
const canRestoreCachedSections =
cached &&
cached.entrySignature === entrySignature &&
(cached.gitStatusSignature ?? '') ===
buildCombinedGitStatusSignature(cached.sections, gitStatusEntries) &&
(cached.entrySignature === entrySignature || canRestoreSnapshotSectionsByKey) &&
(!shouldAutoReloadFromGitStatus ||
(cached.gitStatusSignature ?? '') ===
buildCombinedGitStatusSignature(cached.sections, gitStatusEntries)) &&
(cached.sections.length > 0 || entries.length === 0)
if (canRestoreCachedSections && cached) {
const collapsedPreference = combinedDiffCollapsedPreference
@@ -469,15 +543,18 @@ export default function CombinedDiffViewer({
cached.loadedIndices.filter((index) => !restoredSections[index]?.loading)
)
loadingIndicesRef.current.clear()
pendingRestoreScrollTopRef.current =
combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
latestDomScrollAnchorRef.current = scrollAnchorRef.current
return
}
pendingRestoreScrollTopRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? null
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? 0
scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null
latestDomScrollAnchorRef.current = scrollAnchorRef.current
setSections(
entries.map((entry) => ({
key: `${'area' in entry ? entry.area : (file.diffSource ?? 'compare')}:${entry.path}`,
key: getCombinedDiffFileTreeSectionKey(treeMode, entry),
path: entry.path,
status: entry.status,
area: 'area' in entry ? entry.area : undefined,
@@ -500,7 +577,15 @@ export default function CombinedDiffViewer({
loadSchedulerRef.current.reset()
generationRef.current += 1
setGeneration((prev) => prev + 1)
}, [entries, entrySignature, file.diffSource, gitStatusEntries, viewStateKey])
}, [
entries,
entrySignature,
gitStatusEntries,
hasUncommittedEntriesSnapshot,
shouldAutoReloadFromGitStatus,
treeMode,
viewStateKey
])
const loadSectionNow = useCallback(
async (index: number) => {
@@ -510,11 +595,13 @@ export default function CombinedDiffViewer({
loadingIndicesRef.current.add(index)
const gen = generationRef.current
const entries = isBranchMode
? branchEntries
: isCommitMode
? commitEntries
: uncommittedEntries
const entries = isAllMode
? allEntries
: isBranchMode
? renderableBranchEntries
: isCommitMode
? commitEntries
: uncommittedEntries
const entry = entries[index]
if (!entry) {
loadingIndicesRef.current.delete(index)
@@ -527,7 +614,7 @@ export default function CombinedDiffViewer({
const connectionId = getConnectionId(file.worktreeId) ?? undefined
const state = useAppStore.getState()
const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId)
if (isBranchMode && branchCompare) {
if ((isBranchMode || (isAllMode && !('area' in entry))) && branchCompare) {
result = await withDiffSectionLoadTimeout(
getRuntimeGitBranchDiff(
{
@@ -629,14 +716,16 @@ export default function CombinedDiffViewer({
branchCompare?.baseOid,
branchCompare?.headOid,
branchCompare?.mergeBase,
branchEntries,
allEntries,
commitCompare?.commitOid,
commitCompare?.parentOid,
commitEntries,
file.filePath,
file.runtimeEnvironmentId,
isAllMode,
isBranchMode,
isCommitMode,
renderableBranchEntries,
uncommittedEntries
]
)
@@ -746,6 +835,7 @@ export default function CombinedDiffViewer({
})
},
overscan: COMBINED_DIFF_OVERSCAN,
initialOffset: () => scrollOffsetRef.current,
getItemKey: (index) => {
const section = sections[index]
if (!section) {
@@ -754,6 +844,103 @@ export default function CombinedDiffViewer({
return `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${generation}`
}
})
const combinedDiffTotalSize = virtualizer.getTotalSize()
const getCombinedDiffSectionKey = useCallback((section: DiffSection): string => section.key, [])
const getCombinedDiffSectionElementKey = useCallback(
(element: Element): string | null =>
element instanceof HTMLElement ? (element.dataset.combinedDiffSectionKey ?? null) : null,
[]
)
const recordCombinedDiffVirtualScrollAnchor = useCallback(
(scrollTop: number): void => {
scrollAnchorRef.current = getVirtualizedScrollAnchorForOffset({
getRowKey: getCombinedDiffSectionKey,
rows: sectionsRef.current,
scrollTop,
virtualItems: virtualizer.getVirtualItems()
})
latestDomScrollAnchorRef.current = null
},
[getCombinedDiffSectionKey, virtualizer]
)
const recordCombinedDiffDomScrollAnchor = useCallback((): boolean => {
const container = scrollContainerRef.current
if (!container) {
return false
}
const containerRect = container.getBoundingClientRect()
const visibleRows = Array.from(
container.querySelectorAll<HTMLElement>('[data-combined-diff-section-row]')
)
.map((row) => {
const key = row.dataset.combinedDiffSectionKey
if (!key || !row.isConnected) {
return null
}
const rect = row.getBoundingClientRect()
if (
rect.height <= 0 ||
rect.bottom <= containerRect.top ||
rect.top >= containerRect.bottom
) {
return null
}
return { key, rect }
})
.filter((row): row is { key: string; rect: DOMRect } => row !== null)
.sort((a, b) => a.rect.top - b.rect.top)
const firstVisible = visibleRows[0]
if (!firstVisible) {
return false
}
const anchor: NonNullable<VirtualizedScrollAnchor> = {
fallbackKeys: visibleRows.slice(1).map((row) => row.key),
key: firstVisible.key,
offset: Math.min(
firstVisible.rect.height,
Math.max(0, containerRect.top - firstVisible.rect.top)
)
}
scrollAnchorRef.current = anchor
latestDomScrollAnchorRef.current = anchor
return true
}, [])
const writeCombinedDiffScrollAnchor = useCallback((): void => {
const anchor = scrollAnchorRef.current
if (anchor) {
setWithLRU(combinedDiffScrollAnchorCache, viewStateKey, anchor)
} else {
combinedDiffScrollAnchorCache.delete(viewStateKey)
}
}, [viewStateKey])
const persistCombinedDiffScrollAnchor = useCallback(
(refreshDomAnchor = true): void => {
if (refreshDomAnchor) {
recordCombinedDiffDomScrollAnchor()
}
writeCombinedDiffScrollAnchor()
},
[recordCombinedDiffDomScrollAnchor, writeCombinedDiffScrollAnchor]
)
useVirtualizedScrollAnchor({
anchorRef: scrollAnchorRef,
getItemElementKey: getCombinedDiffSectionElementKey,
getRowKey: getCombinedDiffSectionKey,
hasDirectScrollInput,
itemElementSelector: '[data-combined-diff-section-row]',
recordAnchorOnCleanup: false,
recordAnchorOnScroll: false,
rows: sections,
scrollElementRef: scrollContainerRef,
shouldSkipRestore: hasDirectScrollInput,
scrollOffsetRef,
totalSize: combinedDiffTotalSize,
virtualizer
})
useLayoutEffect(() => {
// Why: inline vs side-by-side can change Monaco content heights across
@@ -799,13 +986,18 @@ export default function CombinedDiffViewer({
)
const handleTreeNavigate = useCallback(
(entry: GitStatusEntry | GitBranchChangeEntry) => {
markDirectScrollInput()
const navigatedIndex = handleCombinedDiffFileTreeNavigation({
mode: treeMode,
entry,
sections: sectionsRef.current,
sectionIndexByKey,
toggleSection,
scrollToIndex: (index) => virtualizer.scrollToIndex(index, { align: 'start' })
scrollToIndex: (index) => {
scrollAnchorRef.current = null
latestDomScrollAnchorRef.current = null
virtualizer.scrollToIndex(index, { align: 'start' })
}
})
if (navigatedIndex !== null) {
// Why: tree navigation is also the user's explicit "show me this diff"
@@ -820,6 +1012,7 @@ export default function CombinedDiffViewer({
},
[
entrySignature,
markDirectScrollInput,
requestCombinedDiffSectionReload,
sectionIndexByKey,
toggleSection,
@@ -855,7 +1048,7 @@ export default function CombinedDiffViewer({
}, [combinedGitStatusSignature, requestCombinedDiffSectionReload, shouldAutoReloadFromGitStatus])
useEffect(() => {
if (treeMode !== 'uncommitted') {
if (treeMode !== 'all' && treeMode !== 'uncommitted') {
return
}
const handler = (event: Event): void => {
@@ -927,7 +1120,9 @@ export default function CombinedDiffViewer({
removed: section.removed
}
if (isBranchMode && branchCompare) {
const isBranchEntry = section.area === undefined
if ((isBranchMode || (isAllMode && isBranchEntry)) && branchCompare) {
openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language)
return
}
@@ -952,6 +1147,7 @@ export default function CombinedDiffViewer({
file.filePath,
file.runtimeEnvironmentId,
file.worktreeId,
isAllMode,
isBranchMode,
isCommitMode,
openBranchDiff,
@@ -1067,20 +1263,82 @@ export default function CombinedDiffViewer({
const cached = combinedDiffViewStateCache.get(viewStateKey)
if (cached && cached.entrySignature === entrySignature) {
pendingRestoreScrollTopRef.current =
combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop
}
const updateCachedScrollPosition = (): void => {
let anchorIdleTimerId: number | null = null
let anchorFrameId: number | null = null
const cancelScheduledAnchorPersist = (): void => {
if (anchorIdleTimerId !== null) {
window.clearTimeout(anchorIdleTimerId)
anchorIdleTimerId = null
}
if (anchorFrameId !== null) {
window.cancelAnimationFrame(anchorFrameId)
anchorFrameId = null
}
}
const scheduleSettledAnchorPersist = (): void => {
cancelScheduledAnchorPersist()
anchorIdleTimerId = window.setTimeout(() => {
anchorIdleTimerId = null
if (hasDirectScrollInput()) {
// Why: the first idle timer can fire while wheel input is still
// active and TanStack may be showing a transitional virtual window.
scheduleSettledAnchorPersist()
return
}
anchorFrameId = window.requestAnimationFrame(() => {
anchorFrameId = null
persistCombinedDiffScrollAnchor()
})
}, 150)
}
const updateCachedScrollPosition = ({
recordDomAnchor,
scheduleSettled,
scrollTop,
writeAnchor
}: {
recordDomAnchor: boolean
scheduleSettled: boolean
scrollTop: number
writeAnchor: boolean
}): void => {
const existing = combinedDiffViewStateCache.get(viewStateKey)
setWithLRU(combinedDiffScrollTopCache, viewStateKey, container.scrollTop)
scrollOffsetRef.current = scrollTop
setWithLRU(combinedDiffScrollTopCache, viewStateKey, scrollTop)
if (writeAnchor) {
if (recordDomAnchor) {
persistCombinedDiffScrollAnchor()
} else {
writeCombinedDiffScrollAnchor()
}
}
if (scheduleSettled) {
scheduleSettledAnchorPersist()
}
updateCombinedDiffScrollbar()
if (!existing || existing.entrySignature !== entrySignature) {
return
}
setWithLRU(combinedDiffViewStateCache, viewStateKey, {
...existing,
scrollTop: container.scrollTop
scrollTop
})
}
const handleScroll = (): void => {
if (!hasDirectScrollInput()) {
updateCombinedDiffScrollbar()
return
}
recordCombinedDiffVirtualScrollAnchor(container.scrollTop)
updateCachedScrollPosition({
recordDomAnchor: false,
scheduleSettled: true,
scrollTop: container.scrollTop,
writeAnchor: true
})
}
@@ -1091,68 +1349,77 @@ export default function CombinedDiffViewer({
updateCombinedDiffScrollbar()
const resizeObserver = new ResizeObserver(updateCombinedDiffScrollbar)
resizeObserver.observe(container)
container.addEventListener('scroll', updateCachedScrollPosition)
container.addEventListener('scroll', handleScroll)
return () => {
updateCachedScrollPosition()
cancelScheduledAnchorPersist()
if (latestDomScrollAnchorRef.current) {
scrollAnchorRef.current = latestDomScrollAnchorRef.current
}
updateCachedScrollPosition({
recordDomAnchor: false,
scheduleSettled: false,
scrollTop: scrollOffsetRef.current,
writeAnchor: true
})
resizeObserver.disconnect()
container.removeEventListener('scroll', updateCachedScrollPosition)
container.removeEventListener('scroll', handleScroll)
}
}, [entrySignature, sections.length, updateCombinedDiffScrollbar, viewStateKey])
}, [
entrySignature,
hasDirectScrollInput,
persistCombinedDiffScrollAnchor,
recordCombinedDiffVirtualScrollAnchor,
sections.length,
updateCombinedDiffScrollbar,
writeCombinedDiffScrollAnchor,
viewStateKey
])
useLayoutEffect(() => {
updateCombinedDiffScrollbar()
}, [sectionHeights, sections, updateCombinedDiffScrollbar])
useLayoutEffect(() => {
const container = scrollContainerRef.current
const targetScrollTop = pendingRestoreScrollTopRef.current
if (!container || targetScrollTop === null) {
if (!container || container.scrollTop <= 0) {
return
}
let frameId = 0
let attempts = 0
const restoreScrollPosition = (): void => {
const liveContainer = scrollContainerRef.current
const liveTarget = pendingRestoreScrollTopRef.current
if (!liveContainer || liveTarget === null) {
let frameId: number | null = null
const timerId = window.setTimeout(() => {
if (!container.isConnected || hasDirectScrollInput()) {
return
}
frameId = window.requestAnimationFrame(() => {
frameId = null
persistCombinedDiffScrollAnchor()
})
}, 300)
const maxScrollTop = Math.max(0, liveContainer.scrollHeight - liveContainer.clientHeight)
const nextScrollTop = Math.min(liveTarget, maxScrollTop)
liveContainer.scrollTop = nextScrollTop
setWithLRU(combinedDiffScrollTopCache, viewStateKey, nextScrollTop)
if (Math.abs(liveContainer.scrollTop - liveTarget) <= 1 || maxScrollTop >= liveTarget) {
pendingRestoreScrollTopRef.current = null
return
}
attempts += 1
if (attempts < 30) {
frameId = window.requestAnimationFrame(restoreScrollPosition)
return () => {
window.clearTimeout(timerId)
if (frameId !== null) {
window.cancelAnimationFrame(frameId)
}
}
restoreScrollPosition()
return () => window.cancelAnimationFrame(frameId)
}, [sectionHeights, sections, viewStateKey])
}, [
hasDirectScrollInput,
persistCombinedDiffScrollAnchor,
sectionHeights,
sections,
updateCombinedDiffScrollbar
])
const openAlternateDiff = useCallback(() => {
if (!file.combinedAlternate) {
return
}
if (file.combinedAlternate.source === 'combined-uncommitted') {
if (file.combinedAlternate.source === 'combined-all') {
openAllDiffs(file.worktreeId, file.filePath)
return
}
if (branchSummary && branchSummary.status === 'ready') {
openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, {
source: 'combined-uncommitted'
source: 'combined-all'
})
}
}, [branchSummary, file, openAllDiffs, openBranchAllDiffs])
@@ -1165,6 +1432,7 @@ export default function CombinedDiffViewer({
}
event.preventDefault()
markDirectScrollInput()
const track = event.currentTarget
const thumb =
event.target instanceof HTMLElement
@@ -1203,6 +1471,7 @@ export default function CombinedDiffViewer({
const handlePointerMove = (moveEvent: PointerEvent): void => {
moveEvent.preventDefault()
markDirectScrollInput()
container.scrollTop = getScrollTopForPointer(moveEvent.clientY, grabOffset)
updateCombinedDiffScrollbar()
}
@@ -1220,7 +1489,7 @@ export default function CombinedDiffViewer({
})
activeScrollbarDragCleanupRef.current = cleanupPointerDrag
},
[cleanupActiveScrollbarDrag, updateCombinedDiffScrollbar]
[cleanupActiveScrollbarDrag, markDirectScrollInput, updateCombinedDiffScrollbar]
)
const handleCopyNotes = useCallback(async (): Promise<void> => {
@@ -1447,7 +1716,7 @@ export default function CombinedDiffViewer({
<span className="truncate text-xs text-muted-foreground">
{sections.length}{' '}
{translate('auto.components.editor.CombinedDiffViewer.7e7ca60816', 'changed files')}
{isBranchMode && branchCompare
{(isAllMode || isBranchMode) && branchCompare
? translate(
'auto.components.editor.CombinedDiffViewer.6094135eec',
' vs {{value0}}',
@@ -1524,7 +1793,7 @@ export default function CombinedDiffViewer({
)
: translate(
'auto.components.editor.CombinedDiffViewer.982d14bfa5',
'Open Uncommitted Diff'
'Open All Changes'
)}
</button>
)}
@@ -1564,12 +1833,11 @@ export default function CombinedDiffViewer({
<div
ref={setScrollContainerRef}
className="combined-diff-scroll-container h-full overflow-auto pr-5 scrollbar-editor"
onWheel={markDirectScrollInput}
onTouchMove={markDirectScrollInput}
>
{skippedConflictNotice}
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
<div className="relative w-full" style={{ height: `${combinedDiffTotalSize}px` }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const section = sections[virtualItem.index]
if (!section) {
@@ -1580,6 +1848,8 @@ export default function CombinedDiffViewer({
<div
key={virtualItem.key}
data-index={virtualItem.index}
data-combined-diff-section-row
data-combined-diff-section-key={section.key}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
// Why: `top` preserves sticky file headers inside each row;
@@ -1601,7 +1871,7 @@ export default function CombinedDiffViewer({
toggleSection={toggleSection}
openSection={openSection}
openSectionTitle={
isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'
isAllMode || isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'
}
setSectionHeights={setSectionHeights}
setSections={setSections}
@@ -199,7 +199,8 @@ export function EditorContent({
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')

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