Merge branch 'main' of https://github.com/stablyai/orca into brennanb2025/renderer-crash-loop-dampening

# Conflicts:
#	src/main/crash-reporting/process-gone-classification.test.ts
#	src/main/crash-reporting/process-gone-classification.ts
This commit is contained in:
brennanb2025
2026-06-23 21:36:38 -07:00
148 changed files with 10314 additions and 1522 deletions
+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.
+23 -17
View File
@@ -83,6 +83,7 @@ import {
type Worktree
} from '../../../src/worktree/workspace-list-sections'
import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sections'
import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage'
import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot'
import { repoColor } from '../../../src/worktree/repo-color'
import {
@@ -90,6 +91,8 @@ import {
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
} from '../../../src/worktree/workspace-list-picker-options'
import type { DesktopStatus, RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
import type { WorkspaceStatusDefinition } from '../../../../src/shared/types'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses'
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
@@ -168,6 +171,9 @@ export function HostScreen({
hideDefaultBranch: false
})
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
// displayName → repo id, populated from repo.list. The filter model keys on
// repo ids (desktop's PersistedUIState), but the section headers/rows key on
// displayName, so we bridge the two here.
@@ -198,6 +204,7 @@ export function HostScreen({
hideDefaultBranch: false,
filterRepoIds: [],
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES,
workspaceHostScope: undefined,
visibleWorkspaceHostIds: undefined
})
@@ -210,10 +217,11 @@ export function HostScreen({
hideDefaultBranch: filters.hideDefaultBranch,
filterRepoIds: [...filters.filterRepoIds],
collapsedGroups: [...collapsedGroups],
workspaceStatuses,
workspaceHostScope: viewStateRef.current.workspaceHostScope,
visibleWorkspaceHostIds: viewStateRef.current.visibleWorkspaceHostIds
}
}, [groupMode, sortMode, filters, collapsedGroups])
}, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses])
// Apply a MobileViewState (e.g. from a desktop ui.get) onto the individual
// states and the snapshot ref in one shot.
@@ -221,6 +229,7 @@ export function HostScreen({
viewStateRef.current = next
setGroupMode(next.groupMode)
setSortMode(next.sortMode)
setWorkspaceStatuses(next.workspaceStatuses)
setCollapsedGroups(new Set(next.collapsedGroups))
setFilters({
filterRepoIds: new Set(next.filterRepoIds),
@@ -787,12 +796,12 @@ export function HostScreen({
}, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeId])
const toggleCollapsed = useCallback(
(title: string) => {
(key: string) => {
const next = new Set(viewStateRef.current.collapsedGroups)
if (next.has(title)) {
next.delete(title)
if (next.has(key)) {
next.delete(key)
} else {
next.add(title)
next.add(key)
}
persistViewSettings({ collapsedGroups: [...next] })
},
@@ -810,7 +819,8 @@ export function HostScreen({
repoColorsByName,
collapsedGroups,
workspaceHostScope: viewStateRef.current.workspaceHostScope,
visibleWorkspaceHostIds: viewStateRef.current.visibleWorkspaceHostIds
visibleWorkspaceHostIds: viewStateRef.current.visibleWorkspaceHostIds,
workspaceStatuses
})
const existingWorktreePaths = useMemo(() => worktrees.map((w) => w.path), [worktrees])
@@ -1160,12 +1170,11 @@ export function HostScreen({
</View>
)}
{/* Worktree list */}
{sections.length > 0 && (
<SectionList
ref={sectionListRef}
sections={sections}
keyExtractor={(w) => w.worktreeId}
keyExtractor={(w) => w.sectionListKey ?? w.worktreeId}
stickySectionHeadersEnabled={false}
onScrollToIndexFailed={onScrollToIndexFailed}
// Why: edge-to-edge — the list scrolls under the system nav bar
@@ -1181,17 +1190,14 @@ export function HostScreen({
if (!section.title) {
return null
}
const isCollapsed = collapsedGroups.has(section.title)
const rawSection = rawSections.find((s) => s.title === section.title)
const isCollapsed = collapsedGroups.has(section.key)
const rawSection = rawSections.find((s) => s.key === section.key)
const count = rawSection?.data.length ?? 0
const repoSectionColor =
groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
const repoSectionIcon = groupMode === 'repo' ? repoIconsByName.get(section.title) : null
return (
<Pressable
style={styles.sectionHeader}
onPress={() => toggleCollapsed(section.title)}
>
<Pressable style={styles.sectionHeader} onPress={() => toggleCollapsed(section.key)}>
{isCollapsed ? (
<ChevronRight size={12} color={colors.textMuted} style={styles.sectionIcon} />
) : (
@@ -1226,12 +1232,14 @@ export function HostScreen({
hideRepo={groupMode === 'repo'}
onPress={openWorktreeSession}
onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget}
onToggleLineage={(row) =>
toggleCollapsed(getMobileWorkspaceLineageGroupKey(row.worktreeId))
}
/>
)}
/>
)}
{/* Sort picker modal */}
<PickerModal
visible={showSortPicker}
title="Sort By"
@@ -1241,7 +1249,6 @@ export function HostScreen({
onClose={() => setShowSortPicker(false)}
/>
{/* Group picker modal */}
<PickerModal
visible={showGroupPicker}
title="Group By"
@@ -1251,7 +1258,6 @@ export function HostScreen({
onClose={() => setShowGroupPicker(false)}
/>
{/* Filter modal — matches desktop's Status + Repositories dropdown */}
<BottomDrawer visible={showFilterModal} onClose={() => setShowFilterModal(false)}>
<View style={styles.filterModalHeader}>
<Text style={styles.filterModalTitle}>Filter</Text>
+2
View File
@@ -68,6 +68,8 @@ export function createMockWorktrees(
parentWorktreeId: null,
childWorktreeIds: [],
displayName: name,
workspaceStatus: index % 9 === 0 ? 'in-review' : 'in-progress',
sortOrder: now - index * 1000,
linkedIssue: index % 7 === 0 ? 200 + index : null,
linkedPR,
linkedLinearIssue: index % 13 === 0 ? `ORC-${index + 1}` : null,
+67 -4
View File
@@ -1,9 +1,9 @@
import { Bell, GitPullRequest } from 'lucide-react-native'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { triggerMediumImpact } from '../platform/haptics'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { AgentSpinner } from './AgentSpinner'
import { MobileRepoIcon } from './MobileRepoIcon'
import { WorktreeAgentList } from './WorktreeAgentList'
@@ -34,6 +34,9 @@ export type WorktreeListRowItem = {
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
lineageDepth?: number
lineageChildCount?: number
lineageCollapsed?: boolean
agents?: RuntimeWorktreeAgentRow[]
}
@@ -51,6 +54,7 @@ type Props<T extends WorktreeListRowItem> = {
status: WorktreeRollupStatus
onPress: (item: T) => void
onLongPress?: (item: T) => void
onToggleLineage?: (item: T) => void
}
export function WorktreeListRow<T extends WorktreeListRowItem>({
@@ -62,16 +66,20 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
hideRepo = false,
status,
onPress,
onLongPress
onLongPress,
onToggleLineage
}: Props<T>) {
const isFolderWorkspace = item.workspaceKind === 'folder-workspace'
const folderMeta = item.comment?.trim() || item.path || 'Folder'
const metaText = isFolderWorkspace ? folderMeta : displayBranch(item.branch)
const lineageDepth = Math.max(0, item.lineageDepth ?? 0)
const lineageChildCount = item.lineageChildCount ?? 0
return (
<Pressable
style={({ pressed }) => [
styles.worktreeRow,
lineageDepth > 0 && { paddingLeft: spacing.lg + lineageDepth * 18 },
item.isActive && styles.worktreeRowActive,
pressed && styles.worktreeRowPressed
]}
@@ -133,6 +141,12 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
/>
</View>
<View style={styles.worktreeMetaRow}>
{lineageDepth > 0 && (
<View style={styles.childBadge}>
<GitBranch size={10} color={colors.textMuted} />
<Text style={styles.childBadgeText}>Child</Text>
</View>
)}
{/* Repo glyph+name only when not already grouped under this repo;
MobileRepoIcon falls back to a Folder (matching desktop's default)
rather than a bare colored dot. */}
@@ -153,6 +167,25 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
{item.agents && item.agents.length > 0 ? (
<WorktreeAgentList agents={item.agents} now={now} unvisited={item.unread} />
) : null}
{lineageChildCount > 0 && onToggleLineage ? (
<Pressable
style={styles.lineageToggle}
onPress={(event) => {
event.stopPropagation()
onToggleLineage(item)
}}
>
{item.lineageCollapsed ? (
<ChevronRight size={12} color={colors.textSecondary} />
) : (
<ChevronDown size={12} color={colors.textSecondary} />
)}
<GitBranch size={12} color={colors.textSecondary} />
<Text style={styles.lineageToggleText}>
{lineageChildCount} {lineageChildCount === 1 ? 'child' : 'children'}
</Text>
</Pressable>
) : null}
</View>
{item.liveTerminalCount > 0 && (
@@ -167,7 +200,8 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: spacing.sm + 2,
paddingHorizontal: spacing.lg,
paddingLeft: spacing.lg,
paddingRight: spacing.lg,
// Reserve the active accent bar width so active/inactive rows align.
borderLeftWidth: 2,
borderLeftColor: 'transparent'
@@ -254,6 +288,35 @@ const styles = StyleSheet.create({
fontFamily: typography.monoFamily,
flexShrink: 1
},
childBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
backgroundColor: colors.bgRaised,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4
},
childBadgeText: {
fontSize: 10,
color: colors.textMuted
},
lineageToggle: {
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: spacing.xs,
backgroundColor: colors.bgRaised,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: radii.button
},
lineageToggleText: {
fontSize: 11,
color: colors.textSecondary,
fontWeight: '600'
},
terminalCount: {
fontSize: typography.metaSize,
color: colors.textMuted,
+12 -1
View File
@@ -224,6 +224,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
var userScale = 1;
var BASE_FONT_PX = 13;
var MIN_FONT_PX = 6;
var MIN_FIT_COLS = 20;
var currentTextScale = 1;
var TEXT_SCALE_PRESETS = ${JSON.stringify([...TERMINAL_TEXT_SCALES])};
var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0];
@@ -255,7 +256,8 @@ export const XTERM_HTML = `<!DOCTYPE html>
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW > 0 && cellH > 0) {
var cols = Math.max(1, Math.floor(window.innerWidth / cellW));
var cols = Math.floor(window.innerWidth / cellW);
if (cols < MIN_FIT_COLS) return;
var rows = Math.max(8, Math.floor(window.innerHeight / cellH));
term.resize(cols, rows);
}
@@ -844,6 +846,15 @@ export const XTERM_HTML = `<!DOCTYPE html>
? containerHeightPx
: window.innerHeight;
var cols = Math.floor(vpWidth / cellWidth);
if (cols < MIN_FIT_COLS) {
flog('measure-skip-small-width', {
vpWidth: vpWidth,
cellWidth: cellWidth,
cols: cols
});
notify({ type: 'measure-result', cols: null, rows: null });
return;
}
// Why: the rows we report become the PTY's actual row count after the
// server fits to viewport, and xterm renders exactly that many lines
// anchored top-left of the WebView. Subtracting rows here would leave
@@ -49,6 +49,13 @@ describe('terminal WebView reflow', () => {
expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows })")
})
it('does not locally resize hidden WebViews to a one-column grid', () => {
expect(htmlSource).toContain('var MIN_FIT_COLS = 20;')
expect(htmlSource).toContain('if (cols < MIN_FIT_COLS) return;')
expect(htmlSource).toContain("flog('measure-skip-small-width'")
expect(htmlSource).toContain("notify({ type: 'measure-result', cols: null, rows: null });")
})
// Why: the raw-source assertions above pass even if the reflow module is
// dropped from the XTERM_HTML concatenation (a broken/removed import or an
// emptied TERMINAL_REFLOW_JS leaves the `${...}` placeholder in the template
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage'
import type { Worktree } from './workspace-list-sections'
function worktree(overrides: Partial<Worktree> = {}): Worktree {
return {
workspaceKind: 'git',
worktreeId: 'worktree',
repoId: 'repo-1',
repo: 'orca',
branch: 'feature/mobile-parity',
displayName: 'worktree',
path: '/tmp/worktree',
liveTerminalCount: 0,
hasAttachedPty: false,
preview: '',
unread: false,
isPinned: false,
linkedPR: null,
status: 'inactive',
agents: [],
...overrides
}
}
describe('applyMobileWorkspaceLineage', () => {
it('places visible children directly under their parent', () => {
const parent = worktree({ worktreeId: 'parent', displayName: 'parent' })
const child = worktree({
worktreeId: 'child',
displayName: 'child',
parentWorktreeId: 'parent'
})
const rows = applyMobileWorkspaceLineage([child, parent])
expect(rows.map((row) => row.worktreeId)).toEqual(['parent', 'child'])
expect(rows.map((row) => row.lineageDepth)).toEqual([0, 1])
expect(rows[0]?.lineageChildCount).toBe(1)
})
it('supports nested lineage chains', () => {
const parent = worktree({ worktreeId: 'parent' })
const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' })
const grandchild = worktree({ worktreeId: 'grandchild', parentWorktreeId: 'child' })
const rows = applyMobileWorkspaceLineage([grandchild, child, parent])
expect(rows.map((row) => row.worktreeId)).toEqual(['parent', 'child', 'grandchild'])
expect(rows.map((row) => row.lineageDepth)).toEqual([0, 1, 2])
})
it('collapses descendants under lineage parents', () => {
const parent = worktree({ worktreeId: 'parent' })
const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' })
const rows = applyMobileWorkspaceLineage([child, parent], new Set(['workspace-lineage:parent']))
expect(rows.map((row) => row.worktreeId)).toEqual(['parent'])
expect(rows[0]?.lineageChildCount).toBe(1)
expect(rows[0]?.lineageCollapsed).toBe(true)
})
it('keeps rows visible when lineage is cyclic', () => {
const first = worktree({ worktreeId: 'first', parentWorktreeId: 'second' })
const second = worktree({ worktreeId: 'second', parentWorktreeId: 'first' })
const rows = applyMobileWorkspaceLineage([first, second])
expect(rows.map((row) => row.worktreeId).sort()).toEqual(['first', 'second'])
})
})
@@ -0,0 +1,74 @@
import type { Worktree } from './workspace-list-types'
export function getMobileWorkspaceLineageGroupKey(worktreeId: string): string {
return `workspace-lineage:${encodeURIComponent(worktreeId)}`
}
export function applyMobileWorkspaceLineage(
worktrees: readonly Worktree[],
collapsedGroups: ReadonlySet<string> = new Set()
): Worktree[] {
const visibleIds = new Set(worktrees.map((worktree) => worktree.worktreeId))
const childrenByParentId = new Map<string, Worktree[]>()
const childIds = new Set<string>()
for (const worktree of worktrees) {
const parentId = worktree.parentWorktreeId
if (!parentId || parentId === worktree.worktreeId || !visibleIds.has(parentId)) {
continue
}
childIds.add(worktree.worktreeId)
const children = childrenByParentId.get(parentId) ?? []
children.push(worktree)
childrenByParentId.set(parentId, children)
}
const result: Worktree[] = []
const emitted = new Set<string>()
const markDescendantsEmitted = (worktree: Worktree): void => {
for (const child of childrenByParentId.get(worktree.worktreeId) ?? []) {
if (!emitted.has(child.worktreeId)) {
emitted.add(child.worktreeId)
markDescendantsEmitted(child)
}
}
}
const emit = (worktree: Worktree, depth: number, isLastChild: boolean): void => {
if (emitted.has(worktree.worktreeId)) {
return
}
const children = childrenByParentId.get(worktree.worktreeId) ?? []
const lineageCollapsed =
children.length > 0 &&
collapsedGroups.has(getMobileWorkspaceLineageGroupKey(worktree.worktreeId))
emitted.add(worktree.worktreeId)
result.push({
...worktree,
lineageDepth: depth,
lineageChildCount: children.length,
lineageCollapsed,
isLastLineageChild: isLastChild
})
if (lineageCollapsed) {
markDescendantsEmitted(worktree)
return
}
children.forEach((child, index) => {
emit(child, depth + 1, index === children.length - 1)
})
}
const roots = worktrees.filter((worktree) => !childIds.has(worktree.worktreeId))
roots.forEach((worktree, index) => {
emit(worktree, 0, index === roots.length - 1)
})
for (const worktree of worktrees) {
if (!emitted.has(worktree.worktreeId)) {
// Why: malformed cyclic lineage should not hide every participant.
emit(worktree, 0, true)
}
}
return result
}
@@ -0,0 +1,42 @@
import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
export const DEFAULT_MOBILE_WORKSPACE_STATUS_ID = 'in-progress'
export const DEFAULT_MOBILE_WORKSPACE_STATUSES = [
{ id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' },
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
{
id: DEFAULT_MOBILE_WORKSPACE_STATUS_ID,
label: 'In progress',
color: 'conductor-progress',
icon: 'conductor-progress'
},
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
] as const satisfies readonly WorkspaceStatusDefinition[]
export function coerceMobileWorkspaceStatuses(
statuses: readonly WorkspaceStatusDefinition[]
): readonly WorkspaceStatusDefinition[] {
return statuses.length > 0 ? statuses : DEFAULT_MOBILE_WORKSPACE_STATUSES
}
export function getMobileWorkspaceStatus(
worktree: { workspaceStatus?: string | null },
statuses: readonly WorkspaceStatusDefinition[]
): string {
const availableStatuses = coerceMobileWorkspaceStatuses(statuses)
if (
worktree.workspaceStatus &&
availableStatuses.some((status) => status.id === worktree.workspaceStatus)
) {
return worktree.workspaceStatus
}
if (availableStatuses.some((status) => status.id === DEFAULT_MOBILE_WORKSPACE_STATUS_ID)) {
return DEFAULT_MOBILE_WORKSPACE_STATUS_ID
}
return availableStatuses[0]?.id ?? DEFAULT_MOBILE_WORKSPACE_STATUS_ID
}
export function getMobileWorkspaceStatusGroupKey(status: string): string {
return `workspace-status:${encodeURIComponent(status)}`
}
+19 -4
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react'
import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
import {
buildSections,
@@ -123,6 +124,7 @@ export function useWorkspaceSections(args: {
collapsedGroups: Set<string>
workspaceHostScope?: string
visibleWorkspaceHostIds?: string[] | null
workspaceStatuses: readonly WorkspaceStatusDefinition[]
}): {
sections: Section[]
rawSections: Section[]
@@ -141,7 +143,8 @@ export function useWorkspaceSections(args: {
repoColorsByName,
collapsedGroups,
workspaceHostScope,
visibleWorkspaceHostIds
visibleWorkspaceHostIds,
workspaceStatuses
} = args
const visibleRepoIdsByName = useMemo(() => {
@@ -189,16 +192,28 @@ export function useWorkspaceSections(args: {
search,
groupMode,
pinnedIds,
visibleRepoIdsByName
visibleRepoIdsByName,
workspaceStatuses,
collapsedGroups
),
[hostScopedWorktrees, sortMode, filters, search, groupMode, pinnedIds, visibleRepoIdsByName]
[
hostScopedWorktrees,
sortMode,
filters,
search,
groupMode,
pinnedIds,
visibleRepoIdsByName,
workspaceStatuses,
collapsedGroups
]
)
const sections = useMemo(
() =>
rawSections.map((s) => ({
...s,
data: collapsedGroups.has(s.title) ? [] : s.data
data: collapsedGroups.has(s.key) ? [] : s.data
})),
[rawSections, collapsedGroups]
)
@@ -2,6 +2,7 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Worktree } from './workspace-list-sections'
import { buildSections, filterWorktrees, getWorktreeStatus } from './workspace-list-sections'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses'
function worktree(overrides: Partial<Worktree> = {}): Worktree {
const worktreePath = join('/tmp', 'orca', 'worktrees', 'feature')
@@ -25,6 +26,22 @@ function worktree(overrides: Partial<Worktree> = {}): Worktree {
}
}
function withoutSectionListKeys(sections: ReturnType<typeof buildSections>) {
return sections.map((section) => ({
...section,
data: section.data.map(
({
sectionListKey: _sectionListKey,
lineageDepth: _lineageDepth,
lineageChildCount: _lineageChildCount,
lineageCollapsed: _lineageCollapsed,
isLastLineageChild: _isLastLineageChild,
...item
}) => item
)
}))
}
describe('filterWorktrees', () => {
it('hides archived worktrees', () => {
const visible = worktree({ worktreeId: 'visible' })
@@ -128,6 +145,29 @@ describe('getWorktreeStatus', () => {
})
describe('buildSections', () => {
it('uses desktop manual order ranks in Manual sort mode', () => {
const low = worktree({ worktreeId: 'low', displayName: 'low', manualOrder: 10 })
const high = worktree({ worktreeId: 'high', displayName: 'high', manualOrder: 30 })
const fallback = worktree({ worktreeId: 'fallback', displayName: 'fallback', sortOrder: 20 })
const sections = buildSections(
[low, high, fallback],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual([
'high',
'fallback',
'low'
])
})
it('renders empty repo sections from repo placeholders in repo grouping', () => {
const sections = buildSections(
[worktree({ repoId: 'repo-1', repo: 'orca' })],
@@ -142,9 +182,13 @@ describe('buildSections', () => {
])
)
expect(sections).toEqual([
{ title: 'orca', data: [worktree({ repoId: 'repo-1', repo: 'orca' })] },
{ title: 'zoom-img', data: [] }
expect(withoutSectionListKeys(sections)).toEqual([
{
key: 'repo:repo-1',
title: 'orca',
data: [worktree({ repoId: 'repo-1', repo: 'orca' })]
},
{ key: 'repo:repo-missing', title: 'zoom-img', data: [] }
])
})
@@ -159,7 +203,7 @@ describe('buildSections', () => {
new Map([['zoom-img', 'repo-missing']])
)
expect(sections).toEqual([])
expect(withoutSectionListKeys(sections)).toEqual([])
})
it('applies repo filters and search to empty repo sections', () => {
@@ -181,7 +225,9 @@ describe('buildSections', () => {
])
)
expect(sections).toEqual([{ title: 'zoom-img', data: [] }])
expect(withoutSectionListKeys(sections)).toEqual([
{ key: 'repo:repo-matching', title: 'zoom-img', data: [] }
])
})
it('does not add an empty repo section when all of its worktrees are filtered out', () => {
@@ -203,6 +249,142 @@ describe('buildSections', () => {
])
)
expect(sections).toEqual([{ title: 'empty-repo', data: [] }])
expect(withoutSectionListKeys(sections)).toEqual([
{ key: 'repo:repo-empty', title: 'empty-repo', data: [] }
])
})
it('groups by desktop workspace status labels and order', () => {
const review = worktree({
worktreeId: 'review',
workspaceStatus: 'in-review',
status: 'active'
})
const progress = worktree({
worktreeId: 'progress',
workspaceStatus: 'in-progress',
status: 'working'
})
const sections = buildSections(
[progress, review],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
expect(sections.map((section) => ({ key: section.key, title: section.title }))).toEqual([
{ key: 'workspace-status:in-review', title: 'In review' },
{ key: 'workspace-status:in-progress', title: 'In progress' }
])
})
it('falls back to the default status catalog when desktop sends none', () => {
const progress = worktree({
worktreeId: 'progress',
workspaceStatus: 'in-progress',
status: 'working'
})
const sections = buildSections(
[progress],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
[]
)
expect(sections.map((section) => ({ key: section.key, title: section.title }))).toEqual([
{ key: 'workspace-status:in-progress', title: 'In progress' }
])
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['progress'])
})
it('does not duplicate pinned worktrees in their canonical status group', () => {
const pinned = worktree({
worktreeId: 'pinned',
workspaceStatus: 'in-progress',
isPinned: true
})
const sections = buildSections(
[pinned],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
expect(withoutSectionListKeys(sections)).toEqual([
{ key: 'pinned', title: 'Pinned', icon: 'pin', data: [pinned] }
])
})
it('nests child workspaces under visible parents in grouped sections', () => {
const parent = worktree({
worktreeId: 'parent',
displayName: 'parent',
workspaceStatus: 'in-progress'
})
const child = worktree({
worktreeId: 'child',
displayName: 'child',
parentWorktreeId: 'parent',
workspaceStatus: 'in-progress'
})
const sections = buildSections(
[child, parent],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent', 'child'])
expect(sections[0]?.data.map((worktree) => worktree.lineageDepth)).toEqual([0, 1])
})
it('collapses child workspaces under lineage parent rows', () => {
const parent = worktree({
worktreeId: 'parent',
displayName: 'parent',
workspaceStatus: 'in-progress'
})
const child = worktree({
worktreeId: 'child',
displayName: 'child',
parentWorktreeId: 'parent',
workspaceStatus: 'in-progress'
})
const sections = buildSections(
[child, parent],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES,
new Set(['workspace-lineage:parent'])
)
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent'])
expect(sections[0]?.data[0]?.lineageChildCount).toBe(1)
expect(sections[0]?.data[0]?.lineageCollapsed).toBe(true)
})
})
+94 -112
View File
@@ -1,54 +1,36 @@
// Pure data transforms for the host workspaces list: status derivation,
// filtering, sorting, and grouping into SectionList sections. Kept out of the
// screen component so the screen stays under its line cap and the logic is
// unit-testable in isolation.
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
import {
DEFAULT_MOBILE_WORKSPACE_STATUSES,
coerceMobileWorkspaceStatuses,
getMobileWorkspaceStatus,
getMobileWorkspaceStatusGroupKey
} from './mobile-workspace-statuses'
import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage'
import { getPRGroupKey, PR_GROUP_LABELS, PR_GROUP_ORDER } from './workspace-pr-status-groups'
import type { FilterState, Section, Worktree } from './workspace-list-types'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
export type Worktree = {
workspaceKind?: 'git' | 'folder-workspace'
worktreeId: string
repoId: string
repo: string
branch: string
displayName: string
// Why: on-disk worktree directory path. Needed by NewWorktreeModal so the
// marine-creature fallback dedupes against the actual filesystem basenames
// (matching the desktop's collision check), not against displayName which
// the user may have renamed.
path: string
isArchived?: boolean
isMainWorktree?: boolean
hasHostSidebarActivity?: boolean
liveTerminalCount: number
hasAttachedPty: boolean
preview: string
unread: boolean
lastOutputAt?: number
isPinned: boolean
isActive?: boolean
linkedPR: { number: number; state: string } | null
linkedIssue?: number | null
linkedLinearIssue?: string | null
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
agents?: RuntimeWorktreeAgentRow[]
}
export type { FilterState, Section, Worktree } from './workspace-list-types'
// Desktop's filter model (shared via PersistedUIState): repos selected by id,
// plus two hide toggles. There is no "active only" — hideSleeping is the
// inverse intent.
export type FilterState = {
filterRepoIds: Set<string>
hideSleeping: boolean
hideDefaultBranch: boolean
function makeSection(
key: string,
title: string,
data: Worktree[],
icon?: 'pin',
collapsedGroups?: ReadonlySet<string>
): Section {
const rows = collapsedGroups ? applyMobileWorkspaceLineage(data, collapsedGroups) : data
return {
key,
title,
...(icon ? { icon } : {}),
data: rows.map((worktree) => ({
...worktree,
sectionListKey: `${key}:${worktree.worktreeId}`
}))
}
}
export type Section = { title: string; icon?: 'pin'; data: Worktree[] }
export function getWorktreeStatus(
w: Worktree
): 'working' | 'active' | 'permission' | 'done' | 'inactive' {
@@ -104,26 +86,28 @@ function isDefaultBranchWorkspace(w: Worktree): boolean {
return branch === 'main' || branch === 'master'
}
export const WORKSPACE_STATUS_LABELS: Record<ReturnType<typeof getWorktreeStatus>, string> = {
permission: 'Needs Permission',
working: 'Working',
done: 'Done',
active: 'Active',
inactive: 'Inactive'
function getManualSortRank(worktree: Worktree): number | null {
const rank = worktree.manualOrder ?? worktree.sortOrder
return typeof rank === 'number' && Number.isFinite(rank) ? rank : null
}
export const WORKSPACE_STATUS_ORDER: ReturnType<typeof getWorktreeStatus>[] = [
'permission',
'working',
'done',
'active',
'inactive'
]
export function sortWorktrees(worktrees: Worktree[], mode: MobileSortMode): Worktree[] {
// 'manual' keeps the server (worktree.ps) order untouched.
if (mode === 'manual') {
return worktrees
return [...worktrees].sort((a, b) => {
const aRank = getManualSortRank(a)
const bRank = getManualSortRank(b)
if (aRank !== null && bRank !== null && aRank !== bRank) {
// Why: desktop assigns higher sort/manual ranks to earlier list positions.
return bRank - aRank
}
if (aRank !== null && bRank === null) {
return -1
}
if (aRank === null && bRank !== null) {
return 1
}
return 0
})
}
return [...worktrees].sort((a, b) => {
if (mode === 'name') {
@@ -136,7 +120,6 @@ export function sortWorktrees(worktrees: Worktree[], mode: MobileSortMode): Work
const repoComparison = a.repo.localeCompare(b.repo, undefined, { sensitivity: 'base' })
return repoComparison || (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
}
// 'smart' — attention-first
if (a.unread !== b.unread) {
return a.unread ? -1 : 1
}
@@ -180,36 +163,6 @@ export function filterWorktrees(
return result
}
// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
// no PR/draft/unknown → "In Progress", open → "In Review", merged → "Done", closed → "Closed"
type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
const PR_GROUP_LABELS: Record<PRGroupKey, string> = {
done: 'Done',
'in-review': 'In Review',
'in-progress': 'In Progress',
closed: 'Closed'
}
const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
function getPRGroupKey(w: Worktree): PRGroupKey {
if (!w.linkedPR) {
return 'in-progress'
}
const s = w.linkedPR.state.toLowerCase()
if (s === 'merged') {
return 'done'
}
if (s === 'closed') {
return 'closed'
}
if (s === 'draft') {
return 'in-progress'
}
return 'in-review'
}
export function isWorktreePinned(w: Worktree, localPins: Set<string>): boolean {
return w.isPinned || localPins.has(w.worktreeId)
}
@@ -221,33 +174,44 @@ export function buildSections(
search: string,
groupMode: MobileGroupMode,
pinnedIds: Set<string>,
repoIdsByName: ReadonlyMap<string, string> = new Map()
repoIdsByName: ReadonlyMap<string, string> = new Map(),
workspaceStatuses: readonly WorkspaceStatusDefinition[] = DEFAULT_MOBILE_WORKSPACE_STATUSES,
collapsedGroups: ReadonlySet<string> = new Set()
): Section[] {
const filtered = filterWorktrees(worktrees, filters, search)
const sorted = sortWorktrees(filtered, sortMode)
const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds))
const unpinned = sorted.filter((w) => !isWorktreePinned(w, pinnedIds))
const active = unpinned.filter(isWorktreeActive)
const inactive = unpinned.filter((w) => !isWorktreeActive(w))
// Why: mobile shows pinned workspaces once in the pinned section; duplicating
// them in status/project sections makes the phone list harder to scan.
const canonicalGroupWorktrees = unpinned
const active = canonicalGroupWorktrees.filter(isWorktreeActive)
const inactive = canonicalGroupWorktrees.filter((w) => !isWorktreeActive(w))
const sections: Section[] = []
if (pinned.length > 0) {
sections.push({ title: 'Pinned', icon: 'pin', data: pinned })
sections.push(makeSection('pinned', 'Pinned', pinned, 'pin'))
}
if (groupMode === 'none') {
if (active.length > 0) {
// Why: without explicit grouping, mobile's primary workflow is jumping
// back into running sessions before browsing the full worktree archive.
sections.push({ title: 'Active', data: active })
sections.push(makeSection('all-active', 'Active', active, undefined, collapsedGroups))
}
if (inactive.length > 0) {
sections.push({ title: pinned.length > 0 || active.length > 0 ? 'All' : '', data: inactive })
sections.push(
makeSection(
'all',
pinned.length > 0 || active.length > 0 ? 'All' : '',
inactive,
undefined,
collapsedGroups
)
)
}
} else if (groupMode === 'repo') {
const byRepo = new Map<string, Worktree[]>()
for (const w of unpinned) {
for (const w of canonicalGroupWorktrees) {
const key = w.repo || 'Unknown'
const list = byRepo.get(key)
if (list) {
@@ -273,12 +237,14 @@ export function buildSections(
}
}
for (const [repo, items] of byRepo) {
sections.push({ title: repo, data: items })
const key = `repo:${repoIdsByName.get(repo) ?? repo}`
sections.push(makeSection(key, repo, items, undefined, collapsedGroups))
}
} else if (groupMode === 'workspaceStatus') {
const byStatus = new Map<ReturnType<typeof getWorktreeStatus>, Worktree[]>()
for (const w of unpinned) {
const key = getWorktreeStatus(w)
const renderableWorkspaceStatuses = coerceMobileWorkspaceStatuses(workspaceStatuses)
const byStatus = new Map<string, Worktree[]>()
for (const w of canonicalGroupWorktrees) {
const key = getMobileWorkspaceStatus(w, renderableWorkspaceStatuses)
const list = byStatus.get(key)
if (list) {
list.push(w)
@@ -286,15 +252,23 @@ export function buildSections(
byStatus.set(key, [w])
}
}
for (const status of WORKSPACE_STATUS_ORDER) {
const items = byStatus.get(status)
for (const status of renderableWorkspaceStatuses) {
const items = byStatus.get(status.id)
if (items && items.length > 0) {
sections.push({ title: WORKSPACE_STATUS_LABELS[status], data: items })
sections.push(
makeSection(
getMobileWorkspaceStatusGroupKey(status.id),
status.label,
items,
undefined,
collapsedGroups
)
)
}
}
} else if (groupMode === 'prStatus') {
const byGroup = new Map<PRGroupKey, Worktree[]>()
for (const w of unpinned) {
const byGroup = new Map<string, Worktree[]>()
for (const w of canonicalGroupWorktrees) {
const key = getPRGroupKey(w)
const list = byGroup.get(key)
if (list) {
@@ -306,7 +280,15 @@ export function buildSections(
for (const groupKey of PR_GROUP_ORDER) {
const items = byGroup.get(groupKey)
if (items && items.length > 0) {
sections.push({ title: PR_GROUP_LABELS[groupKey], data: items })
sections.push(
makeSection(
`pr:${groupKey}`,
PR_GROUP_LABELS[groupKey],
items,
undefined,
collapsedGroups
)
)
}
}
}
@@ -0,0 +1,49 @@
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
export type Worktree = {
sectionListKey?: string
workspaceKind?: 'git' | 'folder-workspace'
worktreeId: string
repoId: string
repo: string
branch: string
displayName: string
workspaceStatus?: string
sortOrder?: number
manualOrder?: number
// Why: on-disk worktree directory path. Needed by NewWorktreeModal so the
// marine-creature fallback dedupes against filesystem basenames.
path: string
isArchived?: boolean
isMainWorktree?: boolean
hasHostSidebarActivity?: boolean
parentWorktreeId?: string | null
childWorktreeIds?: string[]
lineageDepth?: number
lineageChildCount?: number
lineageCollapsed?: boolean
isLastLineageChild?: boolean
liveTerminalCount: number
hasAttachedPty: boolean
preview: string
unread: boolean
lastOutputAt?: number
isPinned: boolean
isActive?: boolean
linkedPR: { number: number; state: string } | null
linkedIssue?: number | null
linkedLinearIssue?: string | null
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
agents?: RuntimeWorktreeAgentRow[]
}
export type FilterState = {
filterRepoIds: Set<string>
hideSleeping: boolean
hideDefaultBranch: boolean
}
export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] }
@@ -0,0 +1,31 @@
import type { Worktree } from './workspace-list-sections'
// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
// no PR/draft/unknown -> "In Progress", open -> "In Review", merged -> "Done", closed -> "Closed"
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
export const PR_GROUP_LABELS: Record<PRGroupKey, string> = {
done: 'Done',
'in-review': 'In Review',
'in-progress': 'In Progress',
closed: 'Closed'
}
export const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
export function getPRGroupKey(w: Worktree): PRGroupKey {
if (!w.linkedPR) {
return 'in-progress'
}
const s = w.linkedPR.state.toLowerCase()
if (s === 'merged') {
return 'done'
}
if (s === 'closed') {
return 'closed'
}
if (s === 'draft') {
return 'in-progress'
}
return 'in-review'
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses'
import {
applyDesktopViewSettings,
groupModeFromDesktop,
@@ -13,7 +14,8 @@ const base: MobileViewState = {
hideSleeping: false,
hideDefaultBranch: false,
filterRepoIds: [],
collapsedGroups: []
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
}
describe('group mode mapping', () => {
@@ -47,12 +49,10 @@ describe('applyDesktopViewSettings', () => {
filterRepoIds: ['repo-1']
})
expect(next).toEqual({
...base,
groupMode: 'prStatus',
sortMode: 'recent', // unchanged (sortBy absent)
hideSleeping: true,
hideDefaultBranch: false, // unchanged
filterRepoIds: ['repo-1'],
collapsedGroups: []
filterRepoIds: ['repo-1']
})
})
@@ -60,6 +60,12 @@ describe('applyDesktopViewSettings', () => {
expect(applyDesktopViewSettings(base, {})).toEqual(base)
})
it('keeps renderable workspace statuses when desktop sends an empty catalog', () => {
const next = applyDesktopViewSettings(base, { workspaceStatuses: [] })
expect(next.workspaceStatuses).toBe(DEFAULT_MOBILE_WORKSPACE_STATUSES)
})
it('preserves current host visibility when desktop omits host fields', () => {
const current: MobileViewState = {
...base,
+12 -1
View File
@@ -3,6 +3,9 @@
// RPCs). Keeping these settings in the same global store is what lets a grouping
// or filter change on the phone show up on desktop and vice-versa.
import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
import { coerceMobileWorkspaceStatuses } from './mobile-workspace-statuses'
export type MobileGroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus'
// Desktop sort adds 'manual'; mobile renders it but sorts by server order.
export type MobileSortMode = 'smart' | 'name' | 'recent' | 'repo' | 'manual'
@@ -17,6 +20,7 @@ export type WorkspaceViewSettings = {
collapsedGroups?: string[]
workspaceHostScope?: string
visibleWorkspaceHostIds?: string[] | null
workspaceStatuses?: WorkspaceStatusDefinition[]
}
const GROUP_TO_DESKTOP: Record<MobileGroupMode, NonNullable<WorkspaceViewSettings['groupBy']>> = {
@@ -62,6 +66,7 @@ export type MobileViewState = {
collapsedGroups: string[]
workspaceHostScope?: string
visibleWorkspaceHostIds?: string[] | null
workspaceStatuses: readonly WorkspaceStatusDefinition[]
}
// Apply a desktop PersistedUIState onto the local view state, leaving any field
@@ -72,13 +77,19 @@ export function applyDesktopViewSettings(
): MobileViewState {
const groupMode = groupModeFromDesktop(settings.groupBy)
const sortMode = sortModeFromDesktop(settings.sortBy)
// Why: a partially hydrated desktop settings payload may carry an empty
// status catalog; mobile must keep renderable groups instead of hiding rows.
const workspaceStatuses = settings.workspaceStatuses
? coerceMobileWorkspaceStatuses(settings.workspaceStatuses)
: current.workspaceStatuses
const next: MobileViewState = {
groupMode: groupMode ?? current.groupMode,
sortMode: sortMode ?? current.sortMode,
hideSleeping: settings.hideSleepingWorkspaces ?? current.hideSleeping,
hideDefaultBranch: settings.hideDefaultBranchWorkspace ?? current.hideDefaultBranch,
filterRepoIds: settings.filterRepoIds ?? current.filterRepoIds,
collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups
collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups,
workspaceStatuses
}
if (settings.workspaceHostScope !== undefined) {
next.workspaceHostScope = settings.workspaceHostScope
@@ -29,6 +29,7 @@ function worktree(overrides: Partial<Worktree> = {}): Worktree {
repo: 'orca',
branch: 'feature/mobile-lag',
displayName: 'manta',
workspaceStatus: 'in-progress',
path: worktreePath,
liveTerminalCount: 1,
hasAttachedPty: true,
@@ -77,6 +78,37 @@ describe('areWorktreeListsEqual', () => {
)
})
it('detects workspace status changes', () => {
expect(
areWorktreeListsEqual(
[worktree({ workspaceStatus: 'in-progress' })],
[worktree({ workspaceStatus: 'in-review' })]
)
).toBe(false)
})
it('detects manual order changes', () => {
expect(
areWorktreeListsEqual(
[worktree({ manualOrder: 10, sortOrder: 1 })],
[worktree({ manualOrder: 20, sortOrder: 1 })]
)
).toBe(false)
})
it('detects lineage changes', () => {
const base = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-a' })
const changedParent = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-b' })
const changedChildren = worktree({
worktreeId: 'child',
parentWorktreeId: 'parent-a',
childWorktreeIds: ['grandchild']
})
expect(areWorktreeListsEqual([base], [changedParent])).toBe(false)
expect(areWorktreeListsEqual([base], [changedChildren])).toBe(false)
})
it('detects agent status changes', () => {
const first = [worktree({ agents: [agent({ state: 'working' })] })]
const second = [worktree({ agents: [agent({ state: 'waiting' })] })]
@@ -26,10 +26,15 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
left.repo === right.repo &&
left.branch === right.branch &&
left.displayName === right.displayName &&
(left.workspaceStatus ?? null) === (right.workspaceStatus ?? null) &&
(left.sortOrder ?? null) === (right.sortOrder ?? null) &&
(left.manualOrder ?? null) === (right.manualOrder ?? null) &&
left.path === right.path &&
(left.isArchived ?? false) === (right.isArchived ?? false) &&
(left.isMainWorktree ?? false) === (right.isMainWorktree ?? false) &&
(left.hasHostSidebarActivity ?? false) === (right.hasHostSidebarActivity ?? false) &&
(left.parentWorktreeId ?? null) === (right.parentWorktreeId ?? null) &&
areStringArraysEqual(left.childWorktreeIds ?? [], right.childWorktreeIds ?? []) &&
left.liveTerminalCount === right.liveTerminalCount &&
left.hasAttachedPty === right.hasAttachedPty &&
left.preview === right.preview &&
@@ -48,6 +53,21 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
)
}
function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean {
if (left === right) {
return true
}
if (left.length !== right.length) {
return false
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false
}
}
return true
}
function arePullRequestsEqual(left: Worktree['linkedPR'], right: Worktree['linkedPR']): boolean {
if (left === right) {
return true
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.94",
"version": "1.4.95-rc.0",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -397,6 +397,7 @@ describe('buildWindowsAgentHookPostCommand', () => {
expect(command).toContain('[Console]::OutputEncoding=$utf8')
expect(command).toContain('$bodyBytes=$utf8.GetBytes($body)')
expect(command).toContain("-ContentType 'application/json; charset=utf-8'")
expect(command).toContain('-TimeoutSec 2')
expect(command).toContain('/hook/codex')
expect(command).not.toContain("'Content-Type'='application/json'")
})
+23
View File
@@ -37,6 +37,29 @@ export type HooksConfig = {
[key: string]: unknown
}
// Why: host-level backstop (seconds) for Orca-managed status hooks. The shell
// wrapper's curl `--max-time 1.5` is the normal dead-endpoint bound; this caps a
// hook the agent host itself runs in case that transport budget is bypassed.
// Intentionally independent of Copilot's `timeoutSec: 5` — both managed budgets
// coexist by design (#4633).
export const MANAGED_HOOK_TIMEOUT_SECONDS = 10
export const MANAGED_HOOK_TIMEOUT_MILLISECONDS = MANAGED_HOOK_TIMEOUT_SECONDS * 1000
// Nested command hook used by the Claude-shaped `hooks: [...]` schema (Claude,
// Codex, Gemini, Droid, Grok, Command Code, Devin).
export function buildManagedCommandHook(
command: string,
timeout = MANAGED_HOOK_TIMEOUT_SECONDS
): HookCommandConfig {
return { type: 'command', command, timeout }
}
// Direct command definition used by schemas that put `command` on the
// definition itself (Cursor's documented top-level shape).
export function buildManagedCommandDefinition(command: string): HookDefinition {
return { command, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -0,0 +1,399 @@
// Why: one regression fixture proves the managed hook timeout budget across every
// managed agent (config entries, wrapper curl flags, and a real dead-endpoint
// shell run) so the cross-agent coverage lives together rather than fragmenting.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { spawn, spawnSync } from 'child_process'
import { createServer, type Server, type Socket } from 'net'
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type * as osModule from 'os'
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-user-data'
}
}))
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof osModule>()
return {
...actual,
homedir: homedirMock.mockImplementation(actual.homedir)
}
})
import { MANAGED_HOOK_TIMEOUT_MILLISECONDS, MANAGED_HOOK_TIMEOUT_SECONDS } from './installer-utils'
import { CodexHookService } from '../codex/hook-service'
import { CursorHookService } from '../cursor/hook-service'
import { CommandCodeHookService } from '../command-code/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
import { AntigravityHookService } from '../antigravity/hook-service'
import { ClaudeHookService } from '../claude/hook-service'
import { GrokHookService } from '../grok/hook-service'
import { CopilotHookService } from '../copilot/hook-service'
import { DevinHookService } from '../devin/hook-service'
import { DroidHookService } from '../droid/hook-service'
import { KimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
type FakeFs = {
files: Map<string, string>
dirs: Set<string>
modes: Map<string, number>
}
function createFakeSftp(initialFiles: Record<string, string> = {}): {
sftp: SFTPWrapper
fs: FakeFs
} {
const fs: FakeFs = {
files: new Map(Object.entries(initialFiles)),
dirs: new Set(['/']),
modes: new Map()
}
const noEntryError = (path: string): { code: number; message: string } => ({
code: 2,
message: `ENOENT ${path}`
})
const sftp = {
readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
const v = fs.files.get(path)
if (v === undefined) {
cb(noEntryError(path))
return
}
cb(null, v)
},
writeFile: (
path: string,
content: string,
options: string | { mode?: number },
cb: (err: unknown) => void
): void => {
fs.files.set(path, content)
if (typeof options !== 'string' && options.mode !== undefined) {
fs.modes.set(path, options.mode)
}
cb(null)
},
rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
const v = fs.files.get(src)
if (v === undefined) {
cb(noEntryError(src))
return
}
fs.files.set(dst, v)
fs.files.delete(src)
const mode = fs.modes.get(src)
if (mode !== undefined) {
fs.modes.set(dst, mode)
fs.modes.delete(src)
}
cb(null)
},
unlink: (path: string, cb: (err: unknown) => void): void => {
fs.files.delete(path)
fs.modes.delete(path)
cb(null)
},
chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
fs.modes.set(path, mode)
cb(null)
},
stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
if (!fs.files.has(path)) {
cb(noEntryError(path))
return
}
cb(null, { mode: fs.modes.get(path) ?? 0o100644 })
},
readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
if (fs.dirs.has(path)) {
cb(null, [])
return
}
cb(noEntryError(path))
},
mkdir: (path: string, cb: (err: unknown) => void): void => {
fs.dirs.add(path)
cb(null)
}
} as unknown as SFTPWrapper
return { sftp, fs }
}
const REMOTE_HOME = '/home/dev'
// Each managed agent that ships an SSH-compatible JSON/TOML hook config. Amp and
// Hermes are intentionally excluded: they are plugin systems with no hook config
// entries, so their transport budgets live in plugin source (see design doc).
const JSON_INSTALLERS = [
{
agent: 'claude',
timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
configPath: `${REMOTE_HOME}/.claude/settings.json`,
install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, REMOTE_HOME)
},
{
agent: 'openclaude',
timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
configPath: `${REMOTE_HOME}/.openclaude/settings.json`,
install: (sftp: SFTPWrapper) => openClaudeHookService.installRemote(sftp, REMOTE_HOME)
},
{
agent: 'codex',
timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
configPath: `${REMOTE_HOME}/.codex/hooks.json`,
install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, REMOTE_HOME)
},
{
agent: 'gemini',
timeout: MANAGED_HOOK_TIMEOUT_MILLISECONDS,
configPath: `${REMOTE_HOME}/.gemini/settings.json`,
install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, REMOTE_HOME)
},
{
agent: 'antigravity',
timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
configPath: `${REMOTE_HOME}/.gemini/config/hooks.json`,
install: (sftp: SFTPWrapper) => new AntigravityHookService().installRemote(sftp, REMOTE_HOME)
},
{
agent: 'cursor',
timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
configPath: `${REMOTE_HOME}/.cursor/hooks.json`,
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, REMOTE_HOME)
},
{
agent: 'command-code',
timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
configPath: `${REMOTE_HOME}/.commandcode/settings.json`,
install: (sftp: SFTPWrapper) => new CommandCodeHookService().installRemote(sftp, REMOTE_HOME)
},
{
agent: 'grok',
timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
configPath: `${REMOTE_HOME}/.grok/hooks/orca-status.json`,
install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, REMOTE_HOME)
},
{
agent: 'copilot',
timeout: 5,
configPath: `${REMOTE_HOME}/.copilot/hooks/orca.json`,
install: (sftp: SFTPWrapper) => new CopilotHookService().installRemote(sftp, REMOTE_HOME)
},
{
agent: 'devin',
timeout: MANAGED_HOOK_TIMEOUT_SECONDS,
configPath: `${REMOTE_HOME}/.config/devin/config.json`,
install: (sftp: SFTPWrapper) => new DevinHookService().installRemote(sftp, REMOTE_HOME)
}
] as const
const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/'
// Walk the parsed config and assert every Orca-managed command carrier (a node
// with a `command`/`bash`/`powershell` string pointing at the managed script
// dir) has a positive config-level timeout sibling (`timeout` or the
// provider-specific `timeoutSec`). Returns the count of managed carriers found
// so callers can assert the scan was not vacuous.
function countManagedCarriersWithTimeout(node: unknown, expectedTimeout: number): number {
if (Array.isArray(node)) {
return node.reduce<number>(
(sum, child) => sum + countManagedCarriersWithTimeout(child, expectedTimeout),
0
)
}
if (node === null || typeof node !== 'object') {
return 0
}
const record = node as Record<string, unknown>
let found = 0
const carrier = [record.command, record.bash, record.powershell].find(
(value): value is string =>
typeof value === 'string' && value.includes(MANAGED_HOOKS_DIR_NEEDLE)
)
if (carrier !== undefined) {
const timeout = typeof record.timeout === 'number' ? record.timeout : record.timeoutSec
expect(typeof timeout, `managed carrier "${carrier}" is missing a config timeout`).toBe(
'number'
)
expect(timeout as number).toBe(expectedTimeout)
found += 1
}
for (const value of Object.values(record)) {
found += countManagedCarriersWithTimeout(value, expectedTimeout)
}
return found
}
describe('managed agent hook timeouts', () => {
it('writes a config-level timeout on every managed JSON hook entry', async () => {
for (const { agent, configPath, install, timeout } of JSON_INSTALLERS) {
const { sftp, fs } = createFakeSftp()
const status = await install(sftp)
expect(status.state, `${agent} install state`).toBe('installed')
const raw = fs.files.get(configPath)
expect(raw, `${agent} config written`).toBeDefined()
const carriers = countManagedCarriersWithTimeout(JSON.parse(raw!), timeout)
expect(
carriers,
`${agent} should have at least one managed timeout-bearing entry`
).toBeGreaterThan(0)
}
})
it('writes a timeout on the managed Kimi TOML hook block', async () => {
const { sftp, fs } = createFakeSftp()
const status = await new KimiHookService().installRemote(sftp, REMOTE_HOME)
expect(status.state).toBe('installed')
const config = fs.files.get(`${REMOTE_HOME}/.kimi-code/config.toml`)!
// One timeout line per managed [[hooks]] event entry.
const timeoutLines = config.match(new RegExp(`timeout = ${MANAGED_HOOK_TIMEOUT_SECONDS}`, 'g'))
expect(timeoutLines?.length ?? 0).toBeGreaterThan(0)
expect(config).toContain('/home/dev/.orca/agent-hooks/kimi-hook.sh')
})
it('writes a config-level timeout on local-only Droid hooks', () => {
const homeDir = mkdtempSync(join(tmpdir(), 'orca-droid-hook-timeout-'))
homedirMock.mockReturnValue(homeDir)
try {
const status = new DroidHookService().install()
expect(status.state).toBe('installed')
const config = JSON.parse(readFileSync(join(homeDir, '.factory', 'settings.json'), 'utf8'))
const carriers = countManagedCarriersWithTimeout(config, MANAGED_HOOK_TIMEOUT_SECONDS)
expect(carriers).toBeGreaterThan(0)
} finally {
homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir())
rmSync(homeDir, { recursive: true, force: true })
}
})
it('bounds every generated POSIX curl wrapper with --connect-timeout and --max-time', async () => {
let curlWrappersChecked = 0
for (const { agent, install } of JSON_INSTALLERS) {
const { sftp, fs } = createFakeSftp()
await install(sftp)
for (const [path, content] of fs.files) {
if (!path.endsWith('.sh')) {
continue
}
expect(content, `${agent} wrapper missing curl transport`).toContain('curl')
expect(content, `${agent} wrapper missing --connect-timeout`).toContain('--connect-timeout')
expect(content, `${agent} wrapper missing --max-time`).toContain('--max-time')
curlWrappersChecked += 1
}
}
const kimi = createFakeSftp()
await new KimiHookService().installRemote(kimi.sftp, REMOTE_HOME)
const kimiWrapper = kimi.fs.files.get(`${REMOTE_HOME}/.orca/agent-hooks/kimi-hook.sh`)!
expect(kimiWrapper, 'kimi wrapper missing --connect-timeout').toContain('--connect-timeout')
expect(kimiWrapper, 'kimi wrapper missing --max-time').toContain('--max-time')
curlWrappersChecked += 1
expect(curlWrappersChecked).toBeGreaterThan(0)
})
describe('dead-endpoint transport budget', () => {
let tempDir: string | null = null
let server: Server | null = null
const openSockets: Socket[] = []
afterEach(() => {
for (const socket of openSockets.splice(0)) {
socket.destroy()
}
if (server) {
server.close()
server = null
}
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
tempDir = null
}
})
const hasPosixCurl =
process.platform !== 'win32' &&
spawnSync('sh', ['-c', 'command -v curl'], { encoding: 'utf8' }).status === 0
function runHookScript(scriptPath: string, port: number): Promise<{ status: number | null }> {
return new Promise((resolve, reject) => {
const child = spawn('sh', [scriptPath], {
env: {
...process.env,
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: String(port),
ORCA_AGENT_HOOK_TOKEN: 'test-token',
ORCA_PANE_KEY: 'pane-1',
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'wt-1'
},
stdio: ['pipe', 'ignore', 'ignore']
})
const timeout = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error('hook script exceeded test timeout'))
}, 10_000)
child.on('error', (error) => {
clearTimeout(timeout)
reject(error)
})
child.on('close', (status) => {
clearTimeout(timeout)
resolve({ status })
})
child.stdin.end('{"hook_event_name":"Stop"}')
})
}
// Why: an accepted-but-unanswered connection is the worst-case hang that only
// `--max-time` (1.5s) governs — `--connect-timeout` already passes once the
// TCP handshake completes. Point the wrapper at a real loopback listener that
// accepts and then stalls, and assert the wrapper still returns well under a
// generous CI budget.
it.skipIf(!hasPosixCurl)(
'returns within budget when the local listener accepts but never responds',
async () => {
// Reuse a real generated POSIX wrapper rather than re-deriving the script.
const { sftp, fs } = createFakeSftp()
await new CodexHookService().installRemote(sftp, REMOTE_HOME)
const wrapperBody = fs.files.get(`${REMOTE_HOME}/.orca/agent-hooks/codex-hook.sh`)!
tempDir = mkdtempSync(join(tmpdir(), 'orca-hook-timeout-'))
const scriptPath = join(tempDir, 'codex-hook.sh')
writeFileSync(scriptPath, wrapperBody, 'utf8')
chmodSync(scriptPath, 0o755)
const stallingServer = createServer((socket) => {
// Accept the connection and hold it open without ever replying.
openSockets.push(socket)
})
server = stallingServer
const port = await new Promise<number>((resolve) => {
stallingServer.listen(0, '127.0.0.1', () => {
const address = stallingServer.address()
resolve(typeof address === 'object' && address ? address.port : 0)
})
})
const startedAt = Date.now()
const result = await runHookScript(scriptPath, port)
const elapsedMs = Date.now() - startedAt
// The hook fails open (`|| true`) and exits 0 even though the POST timed out.
expect(result.status).toBe(0)
expect(openSockets.length).toBeGreaterThan(0)
expect(elapsedMs).toBeGreaterThanOrEqual(1_000)
// --max-time 1.5s + process overhead; a hang would blow well past this.
expect(elapsedMs).toBeLessThan(6_000)
}
)
})
})
+6 -2
View File
@@ -6,9 +6,11 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
MANAGED_HOOK_TIMEOUT_SECONDS,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
@@ -228,10 +230,12 @@ function buildEventDefinition(event: AntigravityEvent, command: string): HookDef
if (event.schema === 'tool') {
return {
matcher: '*',
hooks: [{ type: 'command', command }]
hooks: [buildManagedCommandHook(command)]
}
}
return { type: 'command', command }
// Antigravity's direct-command event schema carries the command on the
// definition; add the host-level timeout backstop alongside it.
return { type: 'command', command, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
}
function removeManagedCommandsFromBundle(
+2 -1
View File
@@ -1,6 +1,7 @@
import { homedir } from 'os'
import { join } from 'path'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
removeManagedCommands,
@@ -101,7 +102,7 @@ export function applyManagedHooks(
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
hooks: [{ type: 'command', command }]
hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
+37 -1
View File
@@ -14,7 +14,7 @@ import { tmpdir } from 'os'
import type * as Os from 'os'
import { join } from 'path'
import { createManagedCommandMatcher, wrapPosixHookCommand } from '../agent-hooks/installer-utils'
import { upsertHookTrustEntriesInContent } from './config-toml-trust'
import { computeTrustedHash, upsertHookTrustEntriesInContent } from './config-toml-trust'
const { getPathMock, homedirMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
@@ -1039,6 +1039,42 @@ describe('CodexHookService', () => {
expect(runtimeToml).not.toContain(':stop:0:0')
})
it('removes legacy managed trust entries hashed before hook timeouts existed', () => {
const service = new CodexHookService()
expect(service.install().state).toBe('installed')
const managedCodexHome = join(userDataDir, 'codex-runtime-home', 'home')
const managedHooksPath = join(managedCodexHome, 'hooks.json')
const runtimeTomlPath = join(managedCodexHome, 'config.toml')
const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
hooks: Record<string, { hooks?: { command?: string }[] }[]>
}
const command = hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command
expect(command).toBeDefined()
const legacyHash = computeTrustedHash({
sourcePath: managedHooksPath,
eventLabel: 'permission_request',
groupIndex: 0,
handlerIndex: 0,
command: command!
})
writeFileSync(
runtimeTomlPath,
[
hookTrustHeader(`${managedHooksPath}:permission_request:0:0`),
'enabled = true',
`trusted_hash = "${legacyHash}"`,
''
].join('\n'),
'utf-8'
)
expect(service.remove().state).toBe('not_installed')
const runtimeToml = readFileSync(runtimeTomlPath, 'utf-8')
expect(runtimeToml).not.toContain(':permission_request:0:0')
})
it('mirrors system Codex config while preserving runtime hook trust on hook install', () => {
const systemCodexHome = join(tmpHome, '.codex')
mkdirSync(systemCodexHome, { recursive: true })
+24 -9
View File
@@ -4,10 +4,12 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
MANAGED_HOOK_TIMEOUT_SECONDS,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
@@ -608,14 +610,19 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void {
if (!managedEventLabels.has(parts.eventLabel)) {
continue
}
const expectedHash = computeTrustedHash({
const expectedEntry: CodexTrustEntry = {
sourcePath: configPath,
eventLabel: parts.eventLabel,
groupIndex: parts.groupIndex,
handlerIndex: parts.handlerIndex,
command
})
if (state.trustedHash !== expectedHash) {
command,
// Why: match the timeout install() wrote, or remove() would fail to
// recognize (and clean up) its own managed trust entries.
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
}
const expectedHash = computeTrustedHash(expectedEntry)
const legacyHash = computeTrustedHash({ ...expectedEntry, timeoutSec: undefined })
if (state.trustedHash !== expectedHash && state.trustedHash !== legacyHash) {
continue
}
ourKeys.push(key)
@@ -749,12 +756,16 @@ export class CodexHookService {
// Why: capture the actual handler index — Codex's hook_key uses the
// positional handlerIndex, and a user-merged hook array can put our
// command at a non-zero slot, so hardcoding 0 would misreport trust.
// Why: the managed hook is written with `timeout` (see install()), and
// Codex folds the handler timeout into its trust hash. Hash the same
// timeout here or status would report every managed hook as stale-trust.
const trustInput: CodexTrustEntry = {
sourcePath: configPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: foundGroupIndex,
handlerIndex: foundHandlerIndex,
command
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
}
const expectedHash = computeTrustedHash(trustInput)
const actualState = trustEntries.get(computeTrustKey(trustInput))
@@ -858,18 +869,21 @@ export class CodexHookService {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
hooks: [{ type: 'command', command }]
hooks: [buildManagedCommandHook(command)]
}
nextHooks[eventName] = [definition, ...cleaned]
// Why: the status hook must run before user hooks so a slow
// PostToolUse/Stop hook cannot leave the sidebar stuck on the previous
// state while Codex visibly reports that hooks are still running.
// timeoutSec mirrors the hook's `timeout` so the trust hash matches the
// entry actually written to hooks.json.
trustEntries.push({
sourcePath: configPath,
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: 0,
handlerIndex: 0,
command
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
}
@@ -946,7 +960,7 @@ export class CodexHookService {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
hooks: [{ type: 'command', command }]
hooks: [buildManagedCommandHook(command)]
}
nextHooks[eventName] = [...cleaned, definition]
trustEntries.push({
@@ -954,7 +968,8 @@ export class CodexHookService {
eventLabel: CODEX_EVENT_LABEL[eventName],
groupIndex: cleaned.length,
handlerIndex: 0,
command
command,
timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS
})
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
@@ -212,7 +213,7 @@ function buildInstalledConfig(
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
hooks: [{ type: 'command', command }]
hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
@@ -282,7 +282,7 @@ describe('shouldRecordProcessGoneCrash', () => {
processType: 'Utility',
serviceName: 'video_capture.mojom.VideoCaptureService',
reason: 'launch-failed',
exitCode: 18,
exitCode: -1,
expectedTeardown: 'none'
})
).toBe(true)
@@ -324,6 +324,36 @@ describe('shouldRecordProcessGoneCrash', () => {
).toBe(true)
})
it('records Windows renderer killed exit 1 outside expected lifecycle teardown only', () => {
expect(
shouldRecordProcessGoneCrash({
source: 'renderer',
processType: 'renderer',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'none'
})
).toBe(true)
expect(
shouldRecordProcessGoneCrash({
source: 'renderer',
processType: 'renderer',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'renderer-reload'
})
).toBe(false)
expect(
shouldRecordProcessGoneCrash({
source: 'renderer',
processType: 'renderer',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'app-shutdown'
})
).toBe(false)
})
it('records non-SIGTERM child-process killed events during renderer-only reloads', () => {
expect(
shouldRecordProcessGoneCrash({
@@ -6,6 +6,8 @@ const RECOVERABLE_CHILD_PROCESS_TYPES = new Set(['gpu'])
const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([
'audio.mojom.AudioService',
'network.mojom.NetworkService',
// Why: Windows media/screen capture can churn this Chromium utility without
// taking down Orca; prompting users for those child exits is noise.
'video_capture.mojom.VideoCaptureService'
])
const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed'])
@@ -4,7 +4,7 @@ import { getProcessGoneDedupeKey, ProcessGoneDedupe } from './process-gone-dedup
describe('ProcessGoneDedupe', () => {
it('suppresses duplicate keys inside the dedupe window', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
const key = getProcessGoneDedupeKey('GPU', 'crashed', 5)
const key = getProcessGoneDedupeKey('child', 'GPU', 'crashed', 5)
expect(dedupe.shouldRecord(key, 1_000)).toBe(true)
expect(dedupe.shouldRecord(key, 2_999)).toBe(false)
@@ -32,4 +32,38 @@ describe('ProcessGoneDedupe', () => {
expect(dedupe.size).toBe(3)
expect(dedupe.shouldRecord('a', 1_004)).toBe(true)
})
it('coalesces renderer crash bursts across crash reasons and exit codes', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
expect(
dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', -36861), 1_000)
).toBe(true)
expect(
dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'oom', -536870904), 1_284)
).toBe(false)
expect(
dedupe.shouldRecord(
getProcessGoneDedupeKey('renderer', 'renderer', 'launch-failed', 18),
1_898
)
).toBe(false)
expect(
dedupe.shouldRecord(
getProcessGoneDedupeKey('renderer', 'renderer', 'launch-failed', 18),
3_000
)
).toBe(true)
})
it('keeps child process crash tuples distinct inside renderer burst windows', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
expect(
dedupe.shouldRecord(getProcessGoneDedupeKey('child', 'Utility', 'crashed', 1), 1_000)
).toBe(true)
expect(
dedupe.shouldRecord(getProcessGoneDedupeKey('child', 'Utility', 'killed', 1), 1_100)
).toBe(true)
})
})
@@ -54,11 +54,17 @@ export class ProcessGoneDedupe {
}
export function getProcessGoneDedupeKey(
source: 'renderer' | 'child',
processType: string,
reason: string,
exitCode: number | null
): string {
return `${processType}:${reason}:${exitCode ?? 'null'}`
// Why: one renderer death can surface as crashed/oom/launch-failed in a
// burst. Coalesce that prompt noise while keeping child identities precise.
if (source === 'renderer') {
return `${source}:${processType}`
}
return `${source}:${processType}:${reason}:${exitCode ?? 'null'}`
}
export const processGoneDedupe = new ProcessGoneDedupe()
+3 -2
View File
@@ -3,6 +3,7 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandDefinition,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
@@ -230,7 +231,7 @@ export class CursorHookService {
// Why: Cursor's documented schema puts `command` directly on the
// definition (not under `hooks`). Emit that shape so cursor-agent
// actually invokes the script.
const definition: HookDefinition = { command }
const definition: HookDefinition = buildManagedCommandDefinition(command)
nextHooks[eventName] = [...cleaned, definition]
}
@@ -280,7 +281,7 @@ export class CursorHookService {
const cleaned = removeManagedCommands(current, isManagedCommand).filter(
(definition) => !isManagedCommand(definition.command as string | undefined)
)
const definition: HookDefinition = { command }
const definition: HookDefinition = buildManagedCommandDefinition(command)
nextHooks[eventName] = [...cleaned, definition]
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { homedir } from 'os'
import { join } from 'path'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
removeManagedCommands,
@@ -74,7 +75,7 @@ export function applyDevinManagedHooks(
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
hooks: [{ type: 'command', command }]
hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { homedir } from 'os'
import { join } from 'path'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
@@ -211,7 +212,7 @@ export class DroidHookService {
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
hooks: [{ type: 'command', command }]
hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
+6 -2
View File
@@ -3,9 +3,11 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
MANAGED_HOOK_TIMEOUT_MILLISECONDS,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
@@ -204,7 +206,8 @@ export class GeminiHookService {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
hooks: [{ type: 'command', command }]
// Why: Gemini's hook `timeout` unit is milliseconds, unlike Claude/Codex.
hooks: [buildManagedCommandHook(command, MANAGED_HOOK_TIMEOUT_MILLISECONDS)]
}
nextHooks[eventName] = [...cleaned, definition]
}
@@ -261,7 +264,8 @@ export class GeminiHookService {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
hooks: [{ type: 'command', command }]
// Why: Gemini's hook `timeout` unit is milliseconds, unlike Claude/Codex.
hooks: [buildManagedCommandHook(command, MANAGED_HOOK_TIMEOUT_MILLISECONDS)]
}
nextHooks[eventName] = [...cleaned, definition]
}
@@ -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()
}
}
}
+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 }
+2 -1
View File
@@ -3,6 +3,7 @@ import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
buildWindowsAgentHookPostCommand,
getSharedManagedScriptPath,
@@ -133,7 +134,7 @@ function buildInstalledConfig(
const cleaned = removeManagedCommands(current, isManagedCommand)
const definition: HookDefinition = {
...event.definition,
hooks: [{ type: 'command', command }]
hooks: [buildManagedCommandHook(command)]
}
nextHooks[event.eventName] = [...cleaned, definition]
}
+1 -1
View File
@@ -939,7 +939,7 @@ function recordProcessGoneCrash(
)
return
}
const key = getProcessGoneDedupeKey(processType, reason, exitCode)
const key = getProcessGoneDedupeKey(source, processType, reason, exitCode)
if (!processGoneDedupe.shouldRecord(key)) {
return
}
+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
}
+10 -1
View File
@@ -6,6 +6,8 @@
// untouched. Appending table headers is always valid TOML, so the block can live
// at the end of any existing file.
import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils'
// Why: mirror the Claude-compatible events Orca normalizes for status. Kimi uses
// these exact event names (see normalizeKimiEvent), so each maps to a
// working/waiting/done transition.
@@ -55,8 +57,15 @@ export function buildManagedKimiHooksBlock(command: string): string {
const commandLiteral = tomlBasicString(command)
// Omit `matcher`: Kimi treats it as a regex (so Claude's literal "*" is
// invalid) and an absent matcher already matches every tool.
// `timeout` is the host-level backstop; the shell wrapper's curl budget is
// the normal dead-endpoint bound.
const entries = KIMI_HOOK_EVENTS.map((event) =>
[`[[hooks]]`, `event = "${event}"`, `command = ${commandLiteral}`].join('\n')
[
`[[hooks]]`,
`event = "${event}"`,
`command = ${commandLiteral}`,
`timeout = ${MANAGED_HOOK_TIMEOUT_SECONDS}`
].join('\n')
)
return [BLOCK_START, ...entries, BLOCK_END].join('\n')
}
@@ -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 }>
+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
+2
View File
@@ -14716,6 +14716,8 @@ describe('OrcaRuntimeService', () => {
parentWorktreeId: null,
childWorktreeIds: [],
displayName: 'foo',
workspaceStatus: 'in-progress',
sortOrder: 0,
linkedIssue: 123,
linkedPR: null,
linkedLinearIssue: null,
+10
View File
@@ -8518,6 +8518,8 @@ export class OrcaRuntimeService {
linkedPR = { number: meta.linkedPR, state: 'unknown' }
}
summaries.set(worktree.id, {
// Why: mobile mirrors desktop workspace grouping/order from persisted
// metadata, while older runtimes may not have hydrated every field yet.
workspaceKind: 'git',
worktreeId: worktree.id,
repoId: worktree.repoId,
@@ -8530,6 +8532,9 @@ export class OrcaRuntimeService {
parentWorktreeId: worktree.parentWorktreeId,
childWorktreeIds: worktree.childWorktreeIds,
displayName: worktree.displayName,
workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
sortOrder: meta?.sortOrder ?? 0,
...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}),
linkedIssue: worktree.linkedIssue,
linkedPR,
linkedLinearIssue: meta?.linkedLinearIssue ?? null,
@@ -8558,6 +8563,8 @@ export class OrcaRuntimeService {
}
const worktree = folderWorkspaceToWorktree(folderWorkspace)
summaries.set(worktree.id, {
// Why: folder workspaces use the same mobile grouping/order contract as
// git worktrees, but legacy records may be missing order metadata.
workspaceKind: 'folder-workspace',
worktreeId: worktree.id,
repoId: worktree.repoId,
@@ -8570,6 +8577,9 @@ export class OrcaRuntimeService {
parentWorktreeId: null,
childWorktreeIds: [],
displayName: worktree.displayName,
workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
sortOrder: worktree.sortOrder ?? 0,
...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}),
linkedIssue: worktree.linkedIssue ?? null,
linkedPR: null,
linkedLinearIssue: worktree.linkedLinearIssue ?? null,
+7 -103
View File
@@ -2,6 +2,7 @@
import type { OrchestrationDb } from './db'
import type { MessageRow, TaskRow, CoordinatorStatus } from './types'
import { buildDispatchPreamble } from './preamble'
import { reconcileLifecycleMessage } from './lifecycle-reconciliation'
export type CoordinatorRuntime = {
sendTerminal(handle: string, action: { text?: string; enter?: boolean }): Promise<unknown>
@@ -249,7 +250,7 @@ export class Coordinator {
for (const msg of messages) {
switch (msg.type) {
case 'worker_done':
this.handleWorkerDone(msg)
this.handleLifecycleMessage(msg)
break
case 'escalation':
this.handleEscalation(msg)
@@ -258,7 +259,7 @@ export class Coordinator {
this.handleDecisionGateMessage(msg)
break
case 'heartbeat':
this.handleHeartbeat(msg)
this.handleLifecycleMessage(msg)
break
case 'status':
this.opts.onLog(`Status from ${msg.from_handle}: ${msg.subject}`)
@@ -273,108 +274,11 @@ export class Coordinator {
this.db.markAsRead(messages.map((m) => m.id))
}
// Why: attribute heartbeats to the specific dispatchId, not a
// (taskId, from_handle) lookup. A task that gets retried after a failed
// dispatch has multiple rows in dispatch_contexts — a late heartbeat from
// the previous (failed) assignee arriving while the new dispatch is active
// would falsely bump the new row's last_heartbeat_at if we resolved by
// "latest dispatch for this task" (§5.3.4). If the worker drops dispatchId
// from the payload, log-and-skip is the preferred failure mode: the stale
// detector will correctly flag the dispatch as hung because nothing
// refreshed last_heartbeat_at.
private handleHeartbeat(msg: MessageRow): void {
if (!msg.payload) {
this.opts.onLog(`Heartbeat from ${msg.from_handle} missing payload; ignored`)
return
private handleLifecycleMessage(msg: MessageRow): void {
const result = reconcileLifecycleMessage(this.db, msg, this.opts.onLog)
if (result.action === 'completed') {
this.state.completedTasks.push(result.taskId)
}
let payload: { dispatchId?: unknown } = {}
try {
payload = JSON.parse(msg.payload)
} catch {
this.opts.onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`)
return
}
const dispatchId = payload.dispatchId
if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
this.opts.onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`)
return
}
this.db.recordHeartbeat(dispatchId, msg.created_at)
}
private handleWorkerDone(msg: MessageRow): void {
this.opts.onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`)
let payload: { taskId?: unknown; dispatchId?: unknown; filesModified?: unknown } = {}
if (msg.payload) {
try {
payload = JSON.parse(msg.payload)
} catch {
this.opts.onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`)
}
}
const taskId = payload.taskId
if (typeof taskId !== 'string' || taskId.length === 0) {
this.opts.onLog(`Warning: worker_done without taskId from ${msg.from_handle}`)
return
}
const dispatchId = payload.dispatchId
if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
this.opts.onLog(`Warning: worker_done without dispatchId from ${msg.from_handle}`)
return
}
const task = this.db.getTask(taskId)
if (!task) {
this.opts.onLog(`Warning: worker_done for unknown task ${taskId}`)
return
}
// Why: taskId alone is not a completion authority; retried tasks can have
// stale worker_done messages racing the current active dispatch.
const dispatch = this.db.getDispatchContextById(dispatchId)
if (!dispatch) {
this.opts.onLog(`Warning: worker_done for unknown dispatch ${dispatchId}`)
return
}
if (dispatch.task_id !== taskId) {
this.opts.onLog(
`Warning: worker_done dispatch ${dispatchId} belongs to ${dispatch.task_id}, not ${taskId}`
)
return
}
if (dispatch.assignee_handle !== msg.from_handle) {
this.opts.onLog(
`Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? '<unknown>'}`
)
return
}
if (dispatch.status !== 'dispatched') {
this.opts.onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`)
return
}
if (this.db.getDispatchContext(taskId)?.id !== dispatchId || task.status !== 'dispatched') {
this.opts.onLog(`Warning: worker_done for stale dispatch ${dispatchId} ignored`)
return
}
const filesModified =
Array.isArray(payload.filesModified) &&
payload.filesModified.every((file) => typeof file === 'string')
? payload.filesModified
: []
const result = JSON.stringify({
completedBy: msg.from_handle,
filesModified,
completedAt: new Date().toISOString()
})
this.db.updateTaskStatus(taskId, 'completed', result)
this.state.completedTasks.push(taskId)
this.opts.onLog(`Task ${taskId} completed`)
}
private handleEscalation(msg: MessageRow): void {
@@ -0,0 +1,144 @@
import type { OrchestrationDb } from './db'
import type { MessageRow } from './types'
export type LifecycleReconciliationResult =
| { action: 'ignored' }
| { action: 'completed'; taskId: string; dispatchId: string }
| { action: 'heartbeat_recorded'; dispatchId: string }
type LogFn = (msg: string) => void
const noopLog: LogFn = () => {}
function parseObjectPayload(msg: MessageRow, onInvalidJson: () => void): Record<string, unknown> {
if (!msg.payload) {
return {}
}
try {
const parsed: unknown = JSON.parse(msg.payload)
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}
} catch {
onInvalidJson()
return {}
}
}
export function reconcileLifecycleMessage(
db: OrchestrationDb,
msg: MessageRow,
onLog: LogFn = noopLog
): LifecycleReconciliationResult {
switch (msg.type) {
case 'worker_done':
return reconcileWorkerDoneMessage(db, msg, onLog)
case 'heartbeat':
return reconcileHeartbeatMessage(db, msg, onLog)
case 'status':
case 'dispatch':
case 'merge_ready':
case 'escalation':
case 'handoff':
case 'decision_gate':
return { action: 'ignored' }
}
}
function reconcileHeartbeatMessage(
db: OrchestrationDb,
msg: MessageRow,
onLog: LogFn
): LifecycleReconciliationResult {
if (!msg.payload) {
onLog(`Heartbeat from ${msg.from_handle} missing payload; ignored`)
return { action: 'ignored' }
}
const payload = parseObjectPayload(msg, () => {
onLog(`Heartbeat from ${msg.from_handle} has invalid JSON payload; ignored`)
})
const dispatchId = payload.dispatchId
if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
onLog(`Heartbeat from ${msg.from_handle} missing dispatchId; ignored`)
return { action: 'ignored' }
}
// Why: dispatchId-specific writes let the DB ignore late heartbeats for
// completed/failed retries without masking a newer hung dispatch.
db.recordHeartbeat(dispatchId, msg.created_at)
return { action: 'heartbeat_recorded', dispatchId }
}
function reconcileWorkerDoneMessage(
db: OrchestrationDb,
msg: MessageRow,
onLog: LogFn
): LifecycleReconciliationResult {
onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`)
const payload = parseObjectPayload(msg, () => {
onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`)
})
const taskId = payload.taskId
if (typeof taskId !== 'string' || taskId.length === 0) {
onLog(`Warning: worker_done without taskId from ${msg.from_handle}`)
return { action: 'ignored' }
}
const dispatchId = payload.dispatchId
if (typeof dispatchId !== 'string' || dispatchId.length === 0) {
onLog(`Warning: worker_done without dispatchId from ${msg.from_handle}`)
return { action: 'ignored' }
}
const task = db.getTask(taskId)
if (!task) {
onLog(`Warning: worker_done for unknown task ${taskId}`)
return { action: 'ignored' }
}
// Why: taskId alone is not a completion authority; retried tasks can have
// stale worker_done messages racing the current active dispatch.
const dispatch = db.getDispatchContextById(dispatchId)
if (!dispatch) {
onLog(`Warning: worker_done for unknown dispatch ${dispatchId}`)
return { action: 'ignored' }
}
if (dispatch.task_id !== taskId) {
onLog(
`Warning: worker_done dispatch ${dispatchId} belongs to ${dispatch.task_id}, not ${taskId}`
)
return { action: 'ignored' }
}
if (dispatch.assignee_handle !== msg.from_handle) {
onLog(
`Warning: worker_done for dispatch ${dispatchId} came from ${msg.from_handle}, expected ${dispatch.assignee_handle ?? '<unknown>'}`
)
return { action: 'ignored' }
}
if (dispatch.status !== 'dispatched') {
onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`)
return { action: 'ignored' }
}
if (db.getDispatchContext(taskId)?.id !== dispatchId || task.status !== 'dispatched') {
onLog(`Warning: worker_done for stale dispatch ${dispatchId} ignored`)
return { action: 'ignored' }
}
const filesModified =
Array.isArray(payload.filesModified) &&
payload.filesModified.every((file) => typeof file === 'string')
? payload.filesModified
: []
const result = JSON.stringify({
completedBy: msg.from_handle,
filesModified,
completedAt: new Date().toISOString()
})
db.updateTaskStatus(taskId, 'completed', result)
onLog(`Task ${taskId} completed`)
return { action: 'completed', taskId, dispatchId }
}
+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',
@@ -377,6 +377,39 @@ describe('orchestration RPC methods', () => {
})
describe('orchestration.check', () => {
function createDispatchedTask(assigneeHandle = 'term_worker') {
const task = db.createTask({ spec: 'manual check work' })
const dispatch = db.createDispatchContext(task.id, assigneeHandle)
return { task, dispatch }
}
function insertWorkerDone(params: {
from?: string
to?: string
taskId?: string
dispatchId?: string
filesModified?: string[]
}): void {
const payload: Record<string, unknown> = {}
if (params.taskId !== undefined) {
payload.taskId = params.taskId
}
if (params.dispatchId !== undefined) {
payload.dispatchId = params.dispatchId
}
if (params.filesModified !== undefined) {
payload.filesModified = params.filesModified
}
db.insertMessage({
from: params.from ?? 'term_worker',
to: params.to ?? 'term_coord',
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify(payload)
})
}
it('returns unread messages for a terminal', async () => {
setup()
db.insertMessage({ from: 'a', to: 'b', subject: 'one' })
@@ -416,6 +449,152 @@ describe('orchestration RPC methods', () => {
expect(result.count).toBe(1)
})
it('reconciles worker_done returned by a waiting manual check', async () => {
setup()
const { task, dispatch } = createDispatchedTask()
vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => {
insertWorkerDone({
taskId: task.id,
dispatchId: dispatch.id,
filesModified: ['src/file.ts']
})
})
const result = (await call('orchestration.check', {
terminal: 'term_coord',
wait: true,
timeoutMs: 100,
types: 'worker_done,escalation,decision_gate'
})) as { count: number; messages: { type: string }[] }
expect(result.count).toBe(1)
expect(result.messages[0].type).toBe('worker_done')
expect(db.getTask(task.id)?.status).toBe('completed')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed')
expect(db.getUnreadMessages('term_coord')).toHaveLength(0)
const taskList = (await call('orchestration.taskList', {})) as {
tasks: {
id: string
status: string
assignee_handle?: string | null
dispatch_id?: string | null
}[]
}
const listedTask = taskList.tasks.find((t) => t.id === task.id)
expect(listedTask?.status).toBe('completed')
expect(listedTask).not.toHaveProperty('assignee_handle')
expect(listedTask).not.toHaveProperty('dispatch_id')
const shownDispatch = (await call('orchestration.dispatchShow', {
task: task.id
})) as { dispatch: { status: string } | null }
expect(shownDispatch.dispatch?.status).toBe('completed')
const completedAt = db.getTask(task.id)?.completed_at
const taskResult = db.getTask(task.id)?.result
const repeated = (await call('orchestration.check', {
terminal: 'term_coord',
types: 'worker_done'
})) as { count: number }
expect(repeated.count).toBe(0)
expect(db.getTask(task.id)?.completed_at).toBe(completedAt)
expect(db.getTask(task.id)?.result).toBe(taskResult)
})
it('keeps check --all read-only for lifecycle messages', async () => {
setup()
const { task, dispatch } = createDispatchedTask()
insertWorkerDone({ taskId: task.id, dispatchId: dispatch.id })
const result = (await call('orchestration.check', {
terminal: 'term_coord',
all: true,
types: 'worker_done'
})) as { count: number }
expect(result.count).toBe(1)
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
expect(db.getUnreadMessages('term_coord', ['worker_done'])).toHaveLength(1)
})
it('does not complete worker_done missing taskId or dispatchId', async () => {
setup()
const { task, dispatch } = createDispatchedTask()
insertWorkerDone({ dispatchId: dispatch.id })
insertWorkerDone({ taskId: task.id })
const result = (await call('orchestration.check', {
terminal: 'term_coord',
types: 'worker_done'
})) as { count: number }
expect(result.count).toBe(2)
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
})
it('does not complete worker_done from a terminal that does not own the dispatch', async () => {
setup()
const { task, dispatch } = createDispatchedTask('term_owner')
insertWorkerDone({
from: 'term_intruder',
taskId: task.id,
dispatchId: dispatch.id
})
const result = (await call('orchestration.check', {
terminal: 'term_coord',
types: 'worker_done'
})) as { count: number }
expect(result.count).toBe(1)
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched')
})
it('does not complete worker_done for a stale inactive dispatch', async () => {
setup()
const task = db.createTask({ spec: 'retry-sensitive work' })
const staleDispatch = db.createDispatchContext(task.id, 'term_old')
db.failDispatch(staleDispatch.id, 'retry elsewhere')
const activeDispatch = db.createDispatchContext(task.id, 'term_current')
insertWorkerDone({
from: 'term_old',
taskId: task.id,
dispatchId: staleDispatch.id
})
const result = (await call('orchestration.check', {
terminal: 'term_coord',
types: 'worker_done'
})) as { count: number }
expect(result.count).toBe(1)
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(staleDispatch.id)?.status).toBe('failed')
expect(db.getDispatchContextById(activeDispatch.id)?.status).toBe('dispatched')
})
it('records heartbeat returned by unread manual check', async () => {
setup()
const { task, dispatch } = createDispatchedTask()
const msg = db.insertMessage({
from: 'term_worker',
to: 'term_coord',
subject: 'alive',
type: 'heartbeat',
payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id })
})
const result = (await call('orchestration.check', {
terminal: 'term_coord',
types: 'heartbeat'
})) as { count: number }
expect(result.count).toBe(1)
expect(db.getDispatchContextById(dispatch.id)?.last_heartbeat_at).toBe(msg.created_at)
})
it('rejects invalid type filters', async () => {
setup()
await expect(
@@ -6,6 +6,7 @@ import type { MessageType, MessagePriority, TaskStatus } from '../../orchestrati
import { buildDispatchPreamble } from '../../orchestration/preamble'
import { formatMessageBanner } from '../../orchestration/formatter'
import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups'
import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation'
import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates'
const MESSAGE_TYPES: MessageType[] = [
@@ -265,6 +266,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
: db.getAllMessagesForHandle(handle, undefined, typeFilter)
if (showUnread && messages.length > 0) {
// Why: manual coordinators can consume lifecycle messages before
// the coordinator loop sees them, but unread `check` is still an
// authoritative read path for worker_done/heartbeat.
for (const message of messages) {
reconcileLifecycleMessage(db, message)
}
db.markAsRead(messages.map((m) => m.id))
}
+1
View File
@@ -2763,6 +2763,7 @@ describe('OrcaRuntimeRpcServer', () => {
path: '/tmp/worktree-a',
branch: 'feature/foo',
linkedIssue: 123,
sortOrder: 0,
unread: true,
liveTerminalCount: 1,
hasAttachedPty: true,
+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
+29
View File
@@ -30,6 +30,7 @@ describe('getStatusOp', () => {
})
afterEach(async () => {
vi.useRealTimers()
clearNoEffectiveUpstreamStatusCache()
await fs.rm(tmpDir, { recursive: true, force: true })
})
@@ -109,6 +110,34 @@ describe('getStatusOp', () => {
).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')) {
+4 -1
View File
@@ -136,7 +136,10 @@ export async function getStatusOp(
try {
upstreamStatus = await readOrProbeNoEffectiveUpstreamStatus(
{ worktreePath, branchName, upstreamName: upstreamStatus?.upstreamName },
(args) => git(args, worktreePath)
(args) => git(args, worktreePath),
{
bypassCache: params.bypassEffectiveUpstreamNegativeCache === true
}
)
} catch {
// Why: status polling should keep returning working-tree entries even
@@ -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)
})
})
+93 -11
View File
@@ -2,7 +2,8 @@ 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 = 30_000
const NO_EFFECTIVE_UPSTREAM_CACHE_TTL_MS = 5 * 60_000
const MAX_NO_EFFECTIVE_UPSTREAM_CACHE_ENTRIES = 512
type NoEffectiveUpstreamCacheIdentity = {
worktreePath: string
@@ -17,6 +18,8 @@ type NoEffectiveUpstreamCacheEntry = {
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')
@@ -37,16 +40,40 @@ function readCachedNoEffectiveUpstreamStatus(
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
@@ -58,39 +85,55 @@ function cacheNoEffectiveUpstreamStatus(
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
runGit: GitCommandRunner,
options: { bypassCache?: boolean } = {}
): Promise<GitUpstreamStatus> {
const cacheKey = noEffectiveUpstreamCacheKey(identity)
const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey)
if (cachedStatus) {
return cachedStatus
}
if (options.bypassCache !== true) {
const cachedStatus = readCachedNoEffectiveUpstreamStatus(cacheKey)
if (cachedStatus) {
return cachedStatus
}
const inFlight = noEffectiveUpstreamInFlight.get(cacheKey)
if (inFlight) {
return inFlight
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)
cacheNoEffectiveUpstreamStatus(cacheKey, status, probedSameNameOriginRef, writeGeneration)
return status
})
noEffectiveUpstreamInFlight.set(cacheKey, probe)
if (options.bypassCache !== true) {
noEffectiveUpstreamInFlight.set(cacheKey, probe)
}
try {
return await probe
} finally {
if (noEffectiveUpstreamInFlight.get(cacheKey) === probe) {
noEffectiveUpstreamInFlight.delete(cacheKey)
trimNoEffectiveUpstreamWriteGeneration()
}
}
}
@@ -98,4 +141,43 @@ export async function readOrProbeNoEffectiveUpstreamStatus(
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
}
+15
View File
@@ -1604,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
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>
)
}
+401 -104
View File
@@ -9,6 +9,7 @@ import {
AlertCircle,
ArrowDownUp,
ArrowRight,
Ban,
Check,
CheckCircle2,
ChevronDown,
@@ -16,6 +17,7 @@ import {
ChevronRight,
CircleDot,
Clock3,
Copy,
EllipsisVertical,
ExternalLink,
Eye,
@@ -106,6 +108,7 @@ import type {
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import {
getGitHubPRPrimaryReviewer,
getGitHubPRReviewerRows,
getGitHubPRReviewLabel,
normalizeGitHubReviewerLogins,
parseGitHubReviewerInputLogins,
@@ -216,6 +219,13 @@ import {
getTaskPageGitHubPRIconTone,
isTaskPageGitHubDraftPR
} from '@/components/task-page-github-work-item-status'
import {
buildTaskPageGitHubCloseUpdate,
getTaskPageGitHubDuplicateCandidates,
getTaskPageGitHubDuplicateTargetErrorMessage,
validateTaskPageGitHubDuplicateTarget,
type TaskPageGitHubCloseAction
} from '@/components/task-page-github-status-actions'
import {
createTaskPageJiraLoadFailureState,
type TaskPageJiraLoadError
@@ -231,6 +241,7 @@ import type {
GitHubOwnerRepo,
GitHubAssignableUser,
GitHubPRMergeMethod,
GitHubIssueUpdate,
GitHubWorkItem,
GitLabTodo,
GitLabWorkItem,
@@ -1021,6 +1032,34 @@ function GHStatusCell({
sourceContext?: TaskSourceContext | null
}): React.JSX.Element {
const patchWorkItem = useAppStore((s) => s.patchWorkItem)
const [statusStateDraft, setStatusStateDraft] = useState(() =>
createTaskPageGitHubStatusStateDraft(item)
)
const [open, setOpen] = useState(false)
const [duplicatePickerOpen, setDuplicatePickerOpen] = useState(false)
const [duplicateSearch, setDuplicateSearch] = useState('')
const [duplicateError, setDuplicateError] = useState<string | null>(null)
const duplicateIssueCandidates = useAppStore(
useShallow((s) => {
if (!duplicatePickerOpen) {
return []
}
const deduped = new Map<number, GitHubWorkItem>()
for (const entry of Object.values(s.workItemsCache)) {
for (const candidate of entry.data ?? []) {
if (
candidate.type === 'issue' &&
candidate.repoId === item.repoId &&
candidate.number !== item.number &&
!deduped.has(candidate.number)
) {
deduped.set(candidate.number, candidate)
}
}
}
return Array.from(deduped.values()).sort((a, b) => b.number - a.number)
})
)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null))
)
@@ -1034,11 +1073,29 @@ function GHStatusCell({
: repoOwnerSettings,
[repoOwnerSettings, sourceContext]
)
const [statusStateDraft, setStatusStateDraft] = useState(() =>
createTaskPageGitHubStatusStateDraft(item)
)
const [open, setOpen] = useState(false)
const reqRef = useRef(0)
const parsedIssueLink = useMemo(() => parseGitHubIssueOrPRLink(item.url), [item.url])
const filteredDuplicateCandidates = useMemo(
() =>
getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch),
[duplicateIssueCandidates, duplicateSearch, item.number]
)
const directDuplicateTarget = useMemo(() => {
const trimmed = duplicateSearch.trim()
const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number)
if (!trimmed || !validation.ok) {
return null
}
if (
filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf)
) {
return null
}
return validation.duplicateOf
}, [duplicateSearch, filteredDuplicateCandidates, item.number])
const duplicatePickerTitle = parsedIssueLink?.slug
? `${parsedIssueLink.slug.owner}/${parsedIssueLink.slug.repo}`
: (repo?.displayName ?? translate('auto.components.TaskPage.repository', 'Repository'))
const resolvedStatusStateDraft = resolveTaskPageGitHubStatusStateDraft(statusStateDraft, item)
if (resolvedStatusStateDraft !== statusStateDraft) {
@@ -1057,38 +1114,71 @@ function GHStatusCell({
)
const handleStateChange = useCallback(
(newState: 'open' | 'closed') => {
if (newState === localState || !repo || item.type !== 'issue') {
(newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => {
if (newState === localState || item.type !== 'issue') {
return
}
const parsedOwnerRepo = parsedIssueLink?.slug
if (!repo && !parsedOwnerRepo) {
return
}
reqRef.current += 1
const reqId = reqRef.current
const updates: GitHubIssueUpdate =
newState === 'closed' && closeAction
? buildTaskPageGitHubCloseUpdate(closeAction)
: { state: newState }
updateLocalState(newState)
patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext })
const target = getActiveRuntimeTarget(sourceSettings)
const runtimeRepoId =
sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id
const updatePromise =
target.kind === 'environment'
? callRuntimeRpc<{ ok?: boolean; error?: string }>(
// Why: issue rows can be sourced by owner/repo URL instead of the local
// repo context; slug-aware writes preserve close reasons and duplicates.
const updatePromise = parsedOwnerRepo
? target.kind === 'environment'
? callRuntimeRpc<{ ok?: boolean; error?: { message?: string } | string }>(
target,
'github.updateIssue',
{ repo: runtimeRepoId, number: item.number, updates: { state: newState } },
'github.project.updateIssueBySlug',
{
owner: parsedOwnerRepo.owner,
repo: parsedOwnerRepo.repo,
number: item.number,
updates
},
{ timeoutMs: 30_000 }
)
: window.api.gh.updateIssue({
repoPath: repo.path,
repoId: repo.id,
sourceContext,
: window.api.gh.updateIssueBySlug({
owner: parsedOwnerRepo.owner,
repo: parsedOwnerRepo.repo,
number: item.number,
updates: { state: newState }
updates
})
: (() => {
if (!repo) {
throw new Error('No GitHub repository context available for this issue.')
}
const runtimeRepoId =
sourceContext?.provider === 'github' ? (sourceContext.repoId ?? repo.id) : repo.id
return target.kind === 'environment'
? callRuntimeRpc<{ ok?: boolean; error?: string }>(
target,
'github.updateIssue',
{ repo: runtimeRepoId, number: item.number, updates },
{ timeoutMs: 30_000 }
)
: window.api.gh.updateIssue({
repoPath: repo.path,
repoId: repo.id,
sourceContext,
number: item.number,
updates
})
})()
updatePromise
.then((result) => {
if (reqId !== reqRef.current) {
return
}
const typed = result as { ok?: boolean; error?: string }
const typed = result as { ok?: boolean; error?: string | { message?: string } }
if (typed && typed.ok === false) {
updateLocalState(newState === 'closed' ? 'open' : 'closed')
patchWorkItem(
@@ -1098,11 +1188,16 @@ function GHStatusCell({
{ sourceContext }
)
toast.error(
typed.error ??
translate('auto.components.TaskPage.1c893195ac', 'Failed to update state')
typeof typed.error === 'string'
? typed.error
: (typed.error?.message ??
translate('auto.components.TaskPage.1c893195ac', 'Failed to update state'))
)
return
}
if (repo) {
useAppStore.getState().evictGitHubRepoCaches(repo.id, repo.path)
}
useAppStore.getState().recordFeatureInteraction('github-tasks')
})
.catch(() => {
@@ -1121,61 +1216,236 @@ function GHStatusCell({
toast.error(translate('auto.components.TaskPage.1c893195ac', 'Failed to update state'))
})
},
[item, localState, patchWorkItem, repo, sourceContext, sourceSettings, updateLocalState]
[
item,
localState,
parsedIssueLink,
patchWorkItem,
repo,
sourceContext,
sourceSettings,
updateLocalState
]
)
if (item.type !== 'issue' || !repo) {
const closeAsDuplicate = useCallback(
(targetIssueNumber: number | string) => {
const validation = validateTaskPageGitHubDuplicateTarget(
String(targetIssueNumber),
item.number
)
if (!validation.ok) {
setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate))
return
}
setDuplicateError(null)
handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf })
setOpen(false)
setDuplicatePickerOpen(false)
},
[handleStateChange, item.number]
)
const handleDuplicateSearchSubmit = useCallback(() => {
const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number)
if (!validation.ok) {
setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate))
return
}
closeAsDuplicate(validation.duplicateOf)
}, [closeAsDuplicate, duplicateSearch, item.number])
const handlePopoverOpenChange = useCallback((nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setDuplicatePickerOpen(false)
setDuplicateSearch('')
setDuplicateError(null)
}
}, [])
if (item.type !== 'issue' || (!repo && !parsedIssueLink?.slug)) {
return <TaskPageGitHubWorkItemStateBadge item={item} />
}
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
className={cn(
'group/status inline-flex cursor-pointer items-center gap-0.5 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-125 hover:ring-1 hover:ring-white/10',
'group/status inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-125 hover:ring-1 hover:ring-white/10',
localState === 'closed'
? 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
)}
>
{localState === 'closed'
? translate('auto.components.TaskPage.d09bf34db7', 'Closed')
: translate('auto.components.TaskPage.606a85c774', 'Open')}
{localState === 'open' ? <CircleDot className="size-2.5" /> : null}
<span>
{localState === 'closed'
? translate('auto.components.TaskPage.d09bf34db7', 'Closed')
: translate('auto.components.TaskPage.606a85c774', 'Open')}
</span>
<ChevronDown className="size-2.5 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => {
handleStateChange('open')
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'open' && 'bg-accent/50'
)}
>
<CircleDot className="size-3 text-emerald-500" />
{translate('auto.components.TaskPage.606a85c774', 'Open')}
</button>
<button
type="button"
onClick={() => {
handleStateChange('closed')
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'closed' && 'bg-accent/50'
)}
>
<CircleDot className="size-3 text-rose-500" />
{translate('auto.components.TaskPage.d09bf34db7', 'Closed')}
</button>
<PopoverContent
className={cn(duplicatePickerOpen ? 'w-[360px]' : 'w-56', 'p-1')}
align="start"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{duplicatePickerOpen ? (
<div>
<div className="flex items-center gap-2 px-1 py-1.5">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7"
onClick={() => {
setDuplicatePickerOpen(false)
setDuplicateSearch('')
setDuplicateError(null)
}}
aria-label={translate('auto.components.TaskPage.backToCloseReasons', 'Back')}
>
<ChevronLeft className="size-4" />
</Button>
<span className="min-w-0 truncate text-[12px] font-semibold">
{duplicatePickerTitle}
</span>
</div>
<div className="relative px-1 pb-2">
<Search className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
<Input
autoFocus
value={duplicateSearch}
onChange={(event) => {
setDuplicateSearch(event.target.value)
setDuplicateError(null)
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
handleDuplicateSearchSubmit()
}
}}
placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')}
className="h-9 pl-8 text-[12px]"
aria-invalid={duplicateError ? true : undefined}
/>
</div>
{duplicateError ? (
<p className="px-2 pb-2 text-[11px] text-destructive">{duplicateError}</p>
) : null}
<div className="scrollbar-sleek max-h-72 overflow-y-auto pr-1">
{directDuplicateTarget ? (
<button
type="button"
onClick={() => closeAsDuplicate(directDuplicateTarget)}
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 text-left hover:bg-accent"
>
<Copy className="size-4 text-primary" />
<span className="min-w-0 flex-1 text-[12px] font-medium">
{translate('auto.components.TaskPage.useIssueNumber', 'Use issue #{{value0}}', {
value0: directDuplicateTarget
})}
</span>
</button>
) : null}
{filteredDuplicateCandidates.map((candidate) => (
<button
key={`${candidate.repoId}:${candidate.number}`}
type="button"
onClick={() => closeAsDuplicate(candidate.number)}
className="flex w-full items-start gap-2 rounded-sm px-2 py-2 text-left hover:bg-accent"
>
{candidate.state === 'closed' ? (
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-primary" />
) : (
<CircleDot className="mt-0.5 size-4 shrink-0 text-emerald-500" />
)}
<span className="min-w-0 flex-1">
<span className="block text-[12px] font-medium leading-snug">
{candidate.title}
</span>
</span>
<span className="shrink-0 text-[12px] text-muted-foreground">
#{candidate.number}
</span>
</button>
))}
{!directDuplicateTarget && filteredDuplicateCandidates.length === 0 ? (
<p className="px-2 py-3 text-[12px] text-muted-foreground">
{translate(
'auto.components.TaskPage.noMatchingIssuesLoaded',
'No matching issues loaded.'
)}
</p>
) : null}
</div>
</div>
) : (
<>
<button
type="button"
onClick={() => {
handleStateChange('open')
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-[12px] hover:bg-accent',
localState === 'open' && 'bg-accent/50'
)}
>
<CircleDot className="size-4 text-muted-foreground" />
{translate('auto.components.TaskPage.606a85c774', 'Open')}
</button>
<button
type="button"
onClick={() => {
handleStateChange('closed', { stateReason: 'completed' })
setOpen(false)
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent',
localState === 'closed' && 'bg-accent/50'
)}
>
<CheckCircle2 className="size-4 text-muted-foreground" />
{translate('auto.components.TaskPage.closeAsCompleted', 'Close as completed')}
</button>
<button
type="button"
onClick={() => {
handleStateChange('closed', { stateReason: 'not_planned' })
setOpen(false)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
<Ban className="size-4 text-muted-foreground" />
{translate('auto.components.TaskPage.closeAsNotPlanned', 'Close as not planned')}
</button>
<button
type="button"
onClick={() => {
setDuplicatePickerOpen(true)
setDuplicateSearch('')
setDuplicateError(null)
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-[12px] hover:bg-accent"
>
<Copy className="size-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{translate('auto.components.TaskPage.closeAsDuplicate', 'Close as duplicate')}
</span>
<ChevronRight className="size-3.5 text-muted-foreground" />
</button>
</>
)}
</PopoverContent>
</Popover>
)
@@ -1195,47 +1465,27 @@ function formatPRDelta(item: GitHubWorkItem): string | null {
return parts.length > 0 ? parts.join(' ') : null
}
function getReviewTone(item: GitHubWorkItem): string {
if (item.reviewDecision === 'APPROVED') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
}
if (item.reviewDecision === 'CHANGES_REQUESTED') {
return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
}
if (item.reviewRequests && item.reviewRequests.length > 0) {
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200'
}
return 'border-border/60 bg-background/70 text-muted-foreground'
}
function ReviewChipAvatar({
reviewer
}: {
reviewer: GitHubPRPrimaryReviewer | null
}): React.JSX.Element {
if (reviewer?.avatarUrl) {
if (reviewer?.login) {
// Why: `gh pr list --json reviewRequests` can return only logins; GitHub's
// public avatar endpoint keeps the list visual aligned with assignee cells.
const avatarUrl = reviewer.avatarUrl || `https://github.com/${reviewer.login}.png?size=40`
return (
<img
src={reviewer.avatarUrl}
src={avatarUrl}
alt=""
loading="lazy"
decoding="async"
title={reviewer.name ? `${reviewer.name} (${reviewer.login})` : reviewer.login}
className="size-3.5 shrink-0 rounded-full border border-border/50 bg-muted object-cover"
className="size-5 shrink-0 rounded-full border border-border/50 bg-muted object-cover"
/>
)
}
if (reviewer?.login) {
return (
<span
title={reviewer.login}
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full border border-border/50 bg-muted text-[8px] font-medium text-muted-foreground"
>
{reviewer.login.slice(0, 1).toUpperCase()}
</span>
)
}
return <Users className="size-3 shrink-0" />
return <Users className="size-5 shrink-0" />
}
function GitHubAssigneeAvatar({ assignee }: { assignee: GitHubAssignableUser }): React.JSX.Element {
@@ -1731,16 +1981,16 @@ function getChecksLabel(item: GitHubWorkItem): string {
return `${summary.passed}/${summary.total} passed`
}
function getChecksTone(item: GitHubWorkItem): string {
function getChecksPillTone(item: GitHubWorkItem): string {
const state = item.checksSummary?.state
if (state === 'success') {
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200'
return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-200'
}
if (state === 'failure') {
return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
return 'border-rose-500/40 bg-rose-500/10 text-rose-200'
}
if (state === 'pending') {
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-200'
return 'border-amber-500/40 bg-amber-500/10 text-amber-200'
}
return 'border-border/60 bg-background/70 text-muted-foreground'
}
@@ -1809,6 +2059,8 @@ function PRReviewCell({
const [localReviewRequests, setLocalReviewRequests] = useState<GitHubAssignableUser[]>(
() => item.reviewRequests ?? []
)
const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom')
const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState<number | null>(null)
const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({
itemId: item.id,
repoId: item.repoId,
@@ -1831,6 +2083,7 @@ function PRReviewCell({
[repoOwnerSettings, sourceContext]
)
const reviewerInputRef = useRef<HTMLInputElement | null>(null)
const reviewerTriggerRef = useRef<HTMLButtonElement | null>(null)
const reviewerInputFocusFrameRef = useRef<number | null>(null)
const cancelReviewerInputFocusFrame = useCallback((): void => {
@@ -1989,6 +2242,8 @@ function PRReviewCell({
const itemWithLocalReviewRequests = { ...item, reviewRequests: localReviewRequests }
const primaryReviewer = getGitHubPRPrimaryReviewer(itemWithLocalReviewRequests)
const reviewerRows = getGitHubPRReviewerRows(itemWithLocalReviewRequests)
const extraReviewerCount = Math.max(0, reviewerRows.length - 1)
const hasReviewerMetadata =
item.reviewDecision !== undefined ||
localReviewRequests.length > 0 ||
@@ -2123,6 +2378,16 @@ function PRReviewCell({
}
const handleReviewerPickerOpenChange = (nextOpen: boolean): void => {
if (nextOpen) {
const rect = reviewerTriggerRef.current?.getBoundingClientRect()
const gap = 8
const availableBelow = rect ? window.innerHeight - rect.bottom - gap : 0
const availableAbove = rect ? rect.top - gap : 0
const nextSide = availableBelow < 240 && availableAbove > availableBelow ? 'top' : 'bottom'
const available = nextSide === 'top' ? availableAbove : availableBelow
setReviewerPickerSide(nextSide)
setReviewerPickerMaxHeight(Math.max(180, Math.min(360, available || 360)))
}
setOpen(nextOpen)
if (nextOpen) {
cancelReviewerInputFocusFrame()
@@ -2191,20 +2456,44 @@ function PRReviewCell({
<Popover open={open} onOpenChange={handleReviewerPickerOpenChange}>
<PopoverTrigger asChild>
<button
ref={reviewerTriggerRef}
type="button"
onClick={(event) => event.stopPropagation()}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-110',
getReviewTone(itemWithLocalReviewRequests)
'inline-flex h-7 max-w-full items-center justify-center text-[12px] font-medium transition hover:brightness-110',
primaryReviewer
? 'gap-1 rounded-full border border-border/40 bg-background/70 px-1.5 text-muted-foreground hover:text-foreground'
: 'min-w-7 text-muted-foreground hover:text-foreground'
)}
aria-label={translate(
'auto.components.TaskPage.editReviewersWithCurrent',
'Edit reviewers: {{value0}}',
{ value0: getGitHubPRReviewLabel(itemWithLocalReviewRequests) }
)}
title={getGitHubPRReviewLabel(itemWithLocalReviewRequests)}
>
<ReviewChipAvatar reviewer={primaryReviewer} />
<span className="truncate">{getGitHubPRReviewLabel(itemWithLocalReviewRequests)}</span>
{primaryReviewer ? (
<>
<ReviewChipAvatar reviewer={primaryReviewer} />
{extraReviewerCount > 0 ? (
<span className="text-[10px] tabular-nums text-muted-foreground">
+{extraReviewerCount}
</span>
) : null}
<ChevronDown className="size-3 text-muted-foreground" />
</>
) : (
<span aria-hidden="true">-</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-[330px] overflow-hidden rounded-md border-border/70 p-0"
className="flex w-[330px] flex-col overflow-hidden rounded-md border-border/70 p-0"
align="start"
side={reviewerPickerSide}
sideOffset={6}
avoidCollisions={false}
style={{ maxHeight: reviewerPickerMaxHeight ? `${reviewerPickerMaxHeight}px` : undefined }}
onClick={(event) => event.stopPropagation()}
onOpenAutoFocus={(event) => {
event.preventDefault()
@@ -2256,7 +2545,7 @@ function PRReviewCell({
}}
/>
</div>
<div className="max-h-[300px] overflow-y-auto scrollbar-sleek">
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
{reviewerMetadata.loading ? (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{translate('auto.components.TaskPage.0eacf48491', 'Loading…')}
@@ -2372,7 +2661,7 @@ function PRChecksCell({
}}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium transition hover:brightness-110',
getChecksTone(item)
getChecksPillTone(item)
)}
>
<Icon className="size-3" />
@@ -2551,7 +2840,7 @@ function PRMergeCell({
)}
>
{merging ? (
<LoaderCircle className="size-3 animate-spin" />
<LoaderCircle className="size-3 animate-spin text-muted-foreground" />
) : (
<GitMerge className="size-3" />
)}
@@ -5290,6 +5579,7 @@ export default function TaskPage(): React.JSX.Element {
)
const activeGithubTaskKind = getGitHubTaskKind(activeTaskPreset, appliedTaskSearch)
const appliedTaskQuery = useMemo(() => parseTaskQuery(appliedTaskSearch), [appliedTaskSearch])
const selectedGitHubRepoExternalLink = useMemo(() => {
if (selectedRepos.length !== 1) {
return null
@@ -6062,11 +6352,18 @@ export default function TaskPage(): React.JSX.Element {
setTaskRefreshNonce((current) => current + 1)
}, [activeGithubTaskKind, setTaskResumeState, taskSearchInput])
const handleTaskSearchChange = useCallback((event: React.ChangeEvent<HTMLInputElement>): void => {
const next = event.target.value
setTaskSearchInput(next)
setActiveTaskPreset(null)
}, [])
const handleTaskSearchChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
const next = event.target.value
const scoped = scopeGitHubTaskSearch(next, activeGithubTaskKind)
setTaskSearchInput(next)
setActiveTaskPreset(null)
// Why: the visible rows are keyed by appliedTaskSearch, not the draft
// input. Hide stale rows as soon as the draft would change the query.
setTasksFiltering(scoped !== appliedTaskSearch)
},
[activeGithubTaskKind, appliedTaskSearch]
)
const handleSetDefaultTaskPreset = useCallback(
(presetId: TaskViewPresetId): void => {
@@ -7884,7 +8181,7 @@ export default function TaskPage(): React.JSX.Element {
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<PRFilterDropdowns
parsed={parseTaskQuery(taskSearchInput)}
parsed={appliedTaskQuery}
kind={activeGithubTaskKind}
authorLogins={loadedGitHubAuthorLogins}
primarySlug={primaryGithubFilterSlug}
@@ -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
@@ -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([])
})
})
@@ -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)
@@ -464,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
@@ -490,12 +543,15 @@ 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: getCombinedDiffFileTreeSectionKey(treeMode, entry),
@@ -521,7 +577,15 @@ export default function CombinedDiffViewer({
loadSchedulerRef.current.reset()
generationRef.current += 1
setGeneration((prev) => prev + 1)
}, [entries, entrySignature, file.diffSource, gitStatusEntries, treeMode, viewStateKey])
}, [
entries,
entrySignature,
gitStatusEntries,
hasUncommittedEntriesSnapshot,
shouldAutoReloadFromGitStatus,
treeMode,
viewStateKey
])
const loadSectionNow = useCallback(
async (index: number) => {
@@ -771,6 +835,7 @@ export default function CombinedDiffViewer({
})
},
overscan: COMBINED_DIFF_OVERSCAN,
initialOffset: () => scrollOffsetRef.current,
getItemKey: (index) => {
const section = sections[index]
if (!section) {
@@ -779,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
@@ -824,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"
@@ -845,6 +1012,7 @@ export default function CombinedDiffViewer({
},
[
entrySignature,
markDirectScrollInput,
requestCombinedDiffSectionReload,
sectionIndexByKey,
toggleSection,
@@ -1095,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
})
}
@@ -1119,54 +1349,63 @@ 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) {
@@ -1193,6 +1432,7 @@ export default function CombinedDiffViewer({
}
event.preventDefault()
markDirectScrollInput()
const track = event.currentTarget
const thumb =
event.target instanceof HTMLElement
@@ -1231,6 +1471,7 @@ export default function CombinedDiffViewer({
const handlePointerMove = (moveEvent: PointerEvent): void => {
moveEvent.preventDefault()
markDirectScrollInput()
container.scrollTop = getScrollTopForPointer(moveEvent.clientY, grabOffset)
updateCombinedDiffScrollbar()
}
@@ -1248,7 +1489,7 @@ export default function CombinedDiffViewer({
})
activeScrollbarDragCleanupRef.current = cleanupPointerDrag
},
[cleanupActiveScrollbarDrag, updateCombinedDiffScrollbar]
[cleanupActiveScrollbarDrag, markDirectScrollInput, updateCombinedDiffScrollbar]
)
const handleCopyNotes = useCallback(async (): Promise<void> => {
@@ -1592,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) {
@@ -1608,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;
@@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { Suspense, act, type ReactElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import { RecoverableRenderErrorBoundary } from './RecoverableRenderErrorBoundary'
const reportCrashMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/react-error-boundary-reporting', () => ({
reportReactErrorBoundaryCrash: reportCrashMock
}))
const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
function createContainer(): { container: HTMLDivElement; root: Root } {
const container = document.createElement('div')
document.body.appendChild(container)
return { container, root: createRoot(container) }
}
function BoundaryHarness({ children }: { children: ReactNode }): ReactElement {
return (
<RecoverableRenderErrorBoundary boundaryId="page.automations" surface="page">
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
</RecoverableRenderErrorBoundary>
)
}
async function flushReactWork(): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
describe('RecoverableRenderErrorBoundary lazy chunk containment', () => {
let root: Root | null = null
let container: HTMLDivElement | null = null
let consoleError: ReturnType<typeof vi.spyOn>
beforeEach(() => {
reportCrashMock.mockReset()
window.sessionStorage.clear()
consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
})
afterEach(() => {
if (root) {
act(() => root?.unmount())
}
container?.remove()
root = null
container = null
window.sessionStorage.clear()
consoleError.mockRestore()
})
it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => {
window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1')
const LazyRejectingImport = lazyWithRetry(
() =>
Promise.reject(
new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js')
),
{ retries: 0 }
)
;({ container, root } = createContainer())
await act(async () => {
root?.render(
<BoundaryHarness>
<LazyRejectingImport />
</BoundaryHarness>
)
})
await flushReactWork()
await flushReactWork()
expect(container?.querySelector('[role="alert"]')).not.toBeNull()
expect(reportCrashMock).not.toHaveBeenCalled()
})
it('still reports ordinary render errors', async () => {
const error = new Error('ordinary render failure')
function BrokenSurface(): ReactElement {
throw error
}
;({ container, root } = createContainer())
await act(async () => {
root?.render(
<BoundaryHarness>
<BrokenSurface />
</BoundaryHarness>
)
})
expect(container?.querySelector('[role="alert"]')).not.toBeNull()
expect(reportCrashMock).toHaveBeenCalledTimes(1)
expect(reportCrashMock).toHaveBeenCalledWith(
expect.objectContaining({
boundaryId: 'page.automations',
surface: 'page',
error
})
)
})
})
@@ -2,6 +2,7 @@ import React from 'react'
import { AlertTriangle, RotateCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { isLazyChunkLoadError } from '@/lib/lazy-with-retry'
import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting'
import type { ReactErrorBoundaryReportArgs } from '../../../../shared/crash-reporting'
import { translate } from '@/i18n/i18n'
@@ -48,6 +49,9 @@ export class RecoverableRenderErrorBoundary extends React.Component<Props, State
if (this.props.reportAsCrash === false) {
return
}
if (isLazyChunkLoadError(error)) {
return
}
void reportReactErrorBoundaryCrash({
boundaryId: this.props.boundaryId,
surface: this.props.surface,
@@ -18,8 +18,9 @@ import {
groupAiVaultSessions
} from './ai-vault-session-filters'
import {
normalizeAiVaultScopeForContext,
shouldRestoreAiVaultProjectScope
DEFAULT_AI_VAULT_SCOPE,
getRestorableAiVaultScope,
normalizeAiVaultScopeForContext
} from './ai-vault-scope-state'
import { buildAiVaultProjectContext } from './ai-vault-session-projects'
import {
@@ -47,7 +48,7 @@ export default function AiVaultPanel(): React.JSX.Element {
const projectHostSetupProjection = useProjectHostSetupProjection()
const agentCmdOverrides = useAppStore((s) => s.settings?.agentCmdOverrides ?? {})
const [query, setQuery] = useState('')
const [scope, setScope] = useState<AiVaultScope>('project')
const [scope, setScope] = useState<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const [sort, setSort] = useState<AiVaultSort>('updated')
const [group, setGroup] = useState<AiVaultGroup>('project')
const [hideEmptySessions, setHideEmptySessions] = useState(true)
@@ -61,6 +62,7 @@ export default function AiVaultPanel(): React.JSX.Element {
const refreshInFlightRef = useRef(false)
const mountedRef = useRef(true)
const userChangedScopeRef = useRef(false)
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
const isRemoteWorktree = Boolean(activeWorktreeRepo?.connectionId)
const activeWorktreePath = activeWorktree?.path ?? null
@@ -91,8 +93,7 @@ export default function AiVaultPanel(): React.JSX.Element {
(group === 'project' ? 0 : 1) +
(hideEmptySessions ? 0 : 1)
// Project scope depends on active project context, but should come back after
// transient context loss unless the user intentionally chose another scope.
// Workspace is the preferred default, but unavailable context still falls back to All.
useEffect(() => {
const normalizedScope = normalizeAiVaultScopeForContext({
scope,
@@ -105,16 +106,17 @@ export default function AiVaultPanel(): React.JSX.Element {
}, [activeProjectKey, activeWorktreePath, scope])
useEffect(() => {
if (
shouldRestoreAiVaultProjectScope({
scope,
activeProjectKey,
userChangedScope: userChangedScopeRef.current
})
) {
setScope('project')
const restorableScope = getRestorableAiVaultScope({
scope,
activeProjectKey,
activeWorktreePath,
preferredScope: preferredScopeRef.current,
userChangedScope: userChangedScopeRef.current
})
if (restorableScope) {
setScope(restorableScope)
}
}, [activeProjectKey, scope])
}, [activeProjectKey, activeWorktreePath, scope])
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise<void> => {
if (refreshInFlightRef.current) {
@@ -284,7 +286,8 @@ export default function AiVaultPanel(): React.JSX.Element {
}, [])
const handleScopeChange = useCallback((nextScope: AiVaultScope) => {
userChangedScopeRef.current = nextScope !== 'project'
preferredScopeRef.current = nextScope
userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE
setScope(nextScope)
}, [])
@@ -36,8 +36,11 @@ import { translate } from '@/i18n/i18n'
const VAULT_HEADER_CONTROL_CLASS = 'size-6 shrink-0'
const VAULT_SCOPE_TOGGLE_ITEM_CLASS =
'h-7 min-h-7 min-w-0 flex-1 basis-0 shrink border border-transparent bg-transparent px-2.5 text-[11px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground @max-[300px]/ai-vault:px-1.5'
// Why: match ToggleGroup's spacing+outline qualifiers so selected edges out-specify its border-l-0 collapse.
const VAULT_SCOPE_SELECTED_EDGE_CLASS =
'data-[spacing=0]:data-[variant=outline]:aria-[checked=true]:border-l data-[spacing=0]:data-[variant=outline]:data-[state=on]:border-l'
const VAULT_SCOPE_TOGGLE_ITEM_CLASS = `h-7 min-h-7 min-w-0 flex-1 basis-0 shrink border border-transparent bg-transparent px-2.5 text-[11px] font-medium leading-none text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground ${VAULT_SCOPE_SELECTED_EDGE_CLASS} @max-[300px]/ai-vault:px-1.5`
export function VaultGroupHeader({
group,
@@ -1,9 +1,17 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_AI_VAULT_SCOPE,
getRestorableAiVaultScope,
normalizeAiVaultScopeForContext,
shouldRestoreAiVaultProjectScope
shouldRestoreDefaultAiVaultScope
} from './ai-vault-scope-state'
describe('DEFAULT_AI_VAULT_SCOPE', () => {
it('defaults the session history scope to workspace', () => {
expect(DEFAULT_AI_VAULT_SCOPE).toBe('workspace')
})
})
describe('normalizeAiVaultScopeForContext', () => {
it('falls back from project to all when no active project is available', () => {
expect(
@@ -44,24 +52,97 @@ describe('normalizeAiVaultScopeForContext', () => {
})
})
describe('shouldRestoreAiVaultProjectScope', () => {
it('restores project after automatic fallback when a project becomes available', () => {
describe('shouldRestoreDefaultAiVaultScope', () => {
it('restores workspace after automatic fallback when a workspace becomes available', () => {
expect(
shouldRestoreAiVaultProjectScope({
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
userChangedScope: false
})
).toBe(true)
})
it('does not restore project after the user manually changed scope', () => {
it('does not restore workspace while the workspace is unavailable', () => {
expect(
shouldRestoreAiVaultProjectScope({
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: null,
userChangedScope: false
})
).toBe(false)
})
it('does not restore workspace after the user manually chose project or all', () => {
expect(
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
userChangedScope: true
})
).toBe(false)
})
it('can restore a project default only when a project becomes available', () => {
expect(
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: null,
activeWorktreePath: '/repo',
userChangedScope: false,
defaultScope: 'project'
})
).toBe(false)
expect(
shouldRestoreDefaultAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
userChangedScope: false,
defaultScope: 'project'
})
).toBe(true)
})
})
describe('getRestorableAiVaultScope', () => {
it('restores the workspace preference after automatic fallback', () => {
expect(
getRestorableAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
preferredScope: 'workspace',
userChangedScope: false
})
).toBe('workspace')
})
it('restores a manual project preference when a project becomes available again', () => {
expect(
getRestorableAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
preferredScope: 'project',
userChangedScope: true
})
).toBe('project')
})
it('keeps a manual all preference sticky', () => {
expect(
getRestorableAiVaultScope({
scope: 'all',
activeProjectKey: 'project:orca',
activeWorktreePath: '/repo',
preferredScope: 'all',
userChangedScope: true
})
).toBeNull()
})
})
@@ -1,5 +1,7 @@
import type { AiVaultScope } from '../../../../shared/ai-vault-types'
export const DEFAULT_AI_VAULT_SCOPE: AiVaultScope = 'workspace'
export function normalizeAiVaultScopeForContext(args: {
scope: AiVaultScope
activeProjectKey: string | null
@@ -14,10 +16,65 @@ export function normalizeAiVaultScopeForContext(args: {
return args.scope
}
export function shouldRestoreAiVaultProjectScope(args: {
function isAiVaultScopeAvailable(args: {
scope: AiVaultScope
activeProjectKey: string | null
userChangedScope: boolean
activeWorktreePath: string | null
}): boolean {
return Boolean(args.activeProjectKey && args.scope === 'all' && !args.userChangedScope)
return normalizeAiVaultScopeForContext(args) === args.scope
}
export function shouldRestoreDefaultAiVaultScope(args: {
scope: AiVaultScope
activeProjectKey: string | null
activeWorktreePath: string | null
userChangedScope: boolean
defaultScope?: AiVaultScope
}): boolean {
const defaultScope = args.defaultScope ?? DEFAULT_AI_VAULT_SCOPE
return (
args.scope === 'all' &&
!args.userChangedScope &&
isAiVaultScopeAvailable({
scope: defaultScope,
activeProjectKey: args.activeProjectKey,
activeWorktreePath: args.activeWorktreePath
})
)
}
export function getRestorableAiVaultScope(args: {
scope: AiVaultScope
activeProjectKey: string | null
activeWorktreePath: string | null
preferredScope: AiVaultScope
userChangedScope: boolean
defaultScope?: AiVaultScope
}): AiVaultScope | null {
const defaultScope = args.defaultScope ?? DEFAULT_AI_VAULT_SCOPE
if (args.preferredScope === defaultScope) {
return shouldRestoreDefaultAiVaultScope({
scope: args.scope,
activeProjectKey: args.activeProjectKey,
activeWorktreePath: args.activeWorktreePath,
userChangedScope: args.userChangedScope,
defaultScope
})
? defaultScope
: null
}
if (args.scope !== 'all' || args.preferredScope === 'all') {
return null
}
return isAiVaultScopeAvailable({
scope: args.preferredScope,
activeProjectKey: args.activeProjectKey,
activeWorktreePath: args.activeWorktreePath
})
? args.preferredScope
: null
}
@@ -1,19 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { refreshGitStatusForWorktree, type GitStatusRefreshDeps } from './git-status-refresh'
import type { GitStatusResult } from '../../../../shared/types'
import {
clearGitStatusRefreshOrderingForTests,
refreshGitStatusForWorktree,
refreshGitStatusForWorktreeStrict,
type GitStatusRefreshDeps
} from './git-status-refresh'
import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/types'
function makeDeps(): GitStatusRefreshDeps {
return {
setGitStatus: vi.fn(),
updateWorktreeGitIdentity: vi.fn(),
setUpstreamStatus: vi.fn(),
fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined)
fetchUpstreamStatus: vi.fn().mockResolvedValue(null)
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
describe('refreshGitStatusForWorktree', () => {
beforeEach(() => {
vi.unstubAllGlobals()
clearGitStatusRefreshOrderingForTests()
})
it('stores status, branch identity, and upstream data from git status', async () => {
@@ -77,7 +97,8 @@ describe('refreshGitStatusForWorktree', () => {
expect(deps.setUpstreamStatus).not.toHaveBeenCalled()
expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, {
runtimeTargetSettings: undefined
runtimeTargetSettings: undefined,
applyUpstreamStatus: false
})
})
@@ -106,7 +127,8 @@ describe('refreshGitStatusForWorktree', () => {
})
expect(deps.setUpstreamStatus).not.toHaveBeenCalled()
expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, {
runtimeTargetSettings: undefined
runtimeTargetSettings: undefined,
applyUpstreamStatus: false
})
})
@@ -133,6 +155,130 @@ describe('refreshGitStatusForWorktree', () => {
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-3', status)
})
it('bypasses automatic no-upstream backoff only for strict refreshes', async () => {
const status: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
}
const gitStatus = vi.fn().mockResolvedValue(status)
vi.stubGlobal('window', { api: { git: { status: gitStatus } } })
const deps = makeDeps()
await refreshGitStatusForWorktree({
worktreeId: 'wt-normal',
worktreePath: '/repo',
deps
})
await refreshGitStatusForWorktreeStrict({
worktreeId: 'wt-strict',
worktreePath: '/repo',
deps
})
expect(gitStatus).toHaveBeenNthCalledWith(1, {
worktreePath: '/repo',
connectionId: undefined
})
expect(gitStatus).toHaveBeenNthCalledWith(2, {
worktreePath: '/repo',
connectionId: undefined,
bypassEffectiveUpstreamNegativeCache: true
})
})
it('does not let an older automatic upstream result overwrite a strict result', async () => {
const automaticStatus = deferred<GitStatusResult>()
const strictStatus: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
}
}
const staleAutomaticStatus: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
}
const gitStatus = vi
.fn()
.mockReturnValueOnce(automaticStatus.promise)
.mockResolvedValueOnce(strictStatus)
vi.stubGlobal('window', { api: { git: { status: gitStatus } } })
const deps = makeDeps()
const automatic = refreshGitStatusForWorktree({
worktreeId: 'wt-race',
worktreePath: '/repo',
deps
})
await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1))
await refreshGitStatusForWorktreeStrict({
worktreeId: 'wt-race',
worktreePath: '/repo',
deps
})
automaticStatus.resolve(staleAutomaticStatus)
await automatic
expect(deps.setGitStatus).toHaveBeenCalledTimes(1)
expect(deps.setGitStatus).toHaveBeenCalledWith('wt-race', strictStatus)
expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledTimes(1)
expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1)
expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-race', strictStatus.upstreamStatus)
})
it('does not let an older automatic explicit upstream fetch overwrite a strict result', async () => {
const automaticFetch = deferred<GitUpstreamStatus | null>()
const strictStatus: GitStatusResult = {
entries: [],
conflictOperation: 'unknown',
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 0,
behind: 1
}
}
const staleAutomaticUpstream: GitUpstreamStatus = { hasUpstream: false, ahead: 0, behind: 0 }
const gitStatus = vi
.fn()
.mockResolvedValueOnce({
entries: [],
conflictOperation: 'unknown'
} satisfies GitStatusResult)
.mockResolvedValueOnce(strictStatus)
vi.stubGlobal('window', { api: { git: { status: gitStatus } } })
const deps = makeDeps()
vi.mocked(deps.fetchUpstreamStatus).mockReturnValueOnce(automaticFetch.promise)
const automatic = refreshGitStatusForWorktree({
worktreeId: 'wt-fetch-race',
worktreePath: '/repo',
deps
})
await vi.waitFor(() => expect(deps.fetchUpstreamStatus).toHaveBeenCalledTimes(1))
await refreshGitStatusForWorktreeStrict({
worktreeId: 'wt-fetch-race',
worktreePath: '/repo',
deps
})
automaticFetch.resolve(staleAutomaticUpstream)
await automatic
expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1)
expect(deps.setUpstreamStatus).toHaveBeenCalledWith(
'wt-fetch-race',
strictStatus.upstreamStatus
)
})
it('clears stale branch identity when git status reports detached HEAD', async () => {
const status: GitStatusResult = {
entries: [],
@@ -18,8 +18,94 @@ export type GitStatusRefreshDeps = {
worktreePath: string,
connectionId?: string,
pushTarget?: GitPushTarget,
options?: { runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null }
) => Promise<void>
options?: {
runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
applyUpstreamStatus?: boolean
}
) => Promise<GitUpstreamStatus | null>
}
const MAX_REFRESH_ORDERING_WORKTREES = 1024
const strictUpstreamRefreshGenerationByWorktree = new Map<string, number>()
const automaticUpstreamRefreshInFlightByWorktree = new Map<string, number>()
function trimRefreshOrderingState(): void {
for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) {
if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) {
break
}
if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) {
continue
}
strictUpstreamRefreshGenerationByWorktree.delete(worktreeId)
}
}
function beginAutomaticUpstreamRefresh(worktreeId: string): number {
automaticUpstreamRefreshInFlightByWorktree.set(
worktreeId,
(automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1
)
return strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0
}
function finishAutomaticUpstreamRefresh(worktreeId: string): void {
const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0
if (count <= 1) {
automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId)
} else {
automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1)
}
trimRefreshOrderingState()
}
function shouldApplyAutomaticUpstreamRefresh(worktreeId: string, startGeneration: number): boolean {
return (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === startGeneration
}
async function fetchAndApplyAutomaticUpstreamStatus({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget,
deps,
startGeneration
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
worktreePath: string
connectionId?: string
pushTarget?: GitPushTarget
deps: GitStatusRefreshDeps
startGeneration: number
}): Promise<void> {
const upstreamStatus = await deps.fetchUpstreamStatus(
worktreeId,
worktreePath,
connectionId,
pushTarget,
{
runtimeTargetSettings: settings,
applyUpstreamStatus: false
}
)
if (upstreamStatus && shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) {
deps.setUpstreamStatus(worktreeId, upstreamStatus)
}
}
function beginStrictUpstreamRefresh(worktreeId: string): void {
strictUpstreamRefreshGenerationByWorktree.set(
worktreeId,
(strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1
)
trimRefreshOrderingState()
}
export function clearGitStatusRefreshOrderingForTests(): void {
strictUpstreamRefreshGenerationByWorktree.clear()
automaticUpstreamRefreshInFlightByWorktree.clear()
}
export async function refreshGitStatusForWorktree({
@@ -37,51 +123,77 @@ export async function refreshGitStatusForWorktree({
pushTarget?: GitPushTarget
deps: GitStatusRefreshDeps
}): Promise<void> {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
const upstreamStartGeneration = beginAutomaticUpstreamRefresh(worktreeId)
try {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`
// gives us the new identity without a separate worktree-list poll.
deps.updateWorktreeGitIdentity(worktreeId, {
head: status.head,
// Why: detached HEAD reports a head oid and no branch. Pass null as an
// explicit clear signal so stale branch names don't linger in the UI.
branch: status.branch ?? (status.head ? null : undefined)
})
if (pushTarget) {
// Why: porcelain status reports Git's configured upstream. Source Control
// actions for PR-created worktrees must instead reconcile with Orca's
// explicit publish target.
await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, {
runtimeTargetSettings: settings
if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, upstreamStartGeneration)) {
return
}
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`
// gives us the new identity without a separate worktree-list poll.
deps.updateWorktreeGitIdentity(worktreeId, {
head: status.head,
// Why: detached HEAD reports a head oid and no branch. Pass null as an
// explicit clear signal so stale branch names don't linger in the UI.
branch: status.branch ?? (status.head ? null : undefined)
})
return
}
if (status.upstreamStatus) {
if (
status.upstreamStatus.ahead > 0 &&
status.upstreamStatus.behind > 0 &&
status.upstreamStatus.behindCommitsArePatchEquivalent === undefined
) {
// Why: porcelain status has counts but cannot tell stale post-rebase
// upstream commits from real remote work. Writing it first makes the
// primary action flicker between Sync and Force Push on every poll.
await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, {
runtimeTargetSettings: settings
if (pushTarget) {
// Why: porcelain status reports Git's configured upstream. Source Control
// actions for PR-created worktrees must instead reconcile with Orca's
// explicit publish target.
await fetchAndApplyAutomaticUpstreamStatus({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget,
deps,
startGeneration: upstreamStartGeneration
})
return
}
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
return
if (status.upstreamStatus) {
if (
status.upstreamStatus.ahead > 0 &&
status.upstreamStatus.behind > 0 &&
status.upstreamStatus.behindCommitsArePatchEquivalent === undefined
) {
// Why: porcelain status has counts but cannot tell stale post-rebase
// upstream commits from real remote work. Writing it first makes the
// primary action flicker between Sync and Force Push on every poll.
await fetchAndApplyAutomaticUpstreamStatus({
settings,
worktreeId,
worktreePath,
connectionId,
deps,
startGeneration: upstreamStartGeneration
})
return
}
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
return
}
await fetchAndApplyAutomaticUpstreamStatus({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget,
deps,
startGeneration: upstreamStartGeneration
})
} finally {
finishAutomaticUpstreamRefresh(worktreeId)
}
await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, {
runtimeTargetSettings: settings
})
}
export async function refreshGitStatusForWorktreeStrict({
@@ -101,12 +213,20 @@ export async function refreshGitStatusForWorktreeStrict({
fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus']
}
}): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
beginStrictUpstreamRefresh(worktreeId)
const status = (await getRuntimeGitStatus(
{
settings,
worktreeId,
worktreePath,
connectionId
},
{
// Why: strict refreshes are user-triggered reconciliation and must not reuse
// automatic polling's no-upstream backoff window.
bypassEffectiveUpstreamNegativeCache: true
}
)) as GitStatusResult
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`
@@ -169,7 +169,8 @@ describe('useGitStatusPolling', () => {
undefined,
undefined,
{
runtimeTargetSettings: { activeRuntimeEnvironmentId: null }
runtimeTargetSettings: { activeRuntimeEnvironmentId: null },
applyUpstreamStatus: false
}
)
})
@@ -192,7 +193,8 @@ describe('useGitStatusPolling', () => {
undefined,
pushTarget,
{
runtimeTargetSettings: { activeRuntimeEnvironmentId: null }
runtimeTargetSettings: { activeRuntimeEnvironmentId: null },
applyUpstreamStatus: false
}
)
})
@@ -100,22 +100,22 @@ function renderNestedStep(repoCount: number): string {
}
describe('AddRepoDialogStepContent nested imports', () => {
it('asks the monorepo question when no repos exist yet', () => {
it('asks the grouping question when no repos exist yet', () => {
const html = renderNestedStep(0)
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('Group these repositories?')
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('Yes, import as group')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})
it('shows the same monorepo import controls after a repo already exists', () => {
it('shows the same grouping import controls after a repo already exists', () => {
const html = renderNestedStep(1)
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('Group these repositories?')
expect(html).toContain('aria-label="Group name"')
expect(html).toContain('Yes, import as group')
expect(html).toContain('No, import separately')
expect(html).not.toContain('>Import</button>')
})
@@ -72,19 +72,19 @@ describe('AddRepoNestedImportStep', () => {
container = null
})
it('asks whether the selected folder is a monorepo', () => {
it('asks whether the selected folder should be grouped', () => {
const html = renderStepMarkup()
expect(html).toContain('Import repositories from folder')
expect(html).toContain('Found 3 repositories in')
expect(html).toContain('/workspace/platform')
expect(html).toContain('aria-label="Monorepo name"')
expect(html).toContain('aria-label="Group name"')
expect(html).not.toContain('What is a')
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('Group these repositories?')
expect(html).toContain('Choose this if these projects belong together')
expect(html).toContain('Orca will group them and let you work from the parent folder')
expect(html).toContain('No, import separately')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('Yes, import as group')
expect(html).toContain('payments/api')
expect(html).toContain('billing/api')
expect(html).not.toContain('disabled=""')
@@ -95,14 +95,14 @@ describe('AddRepoNestedImportStep', () => {
it('disables both import actions while scanning', () => {
const html = renderStepMarkup({ scanInProgress: true })
expect(html).toContain('Is this a monorepo?')
expect(html).toContain('Group these repositories?')
expect(html).toContain('No, import separately')
expect(html).toContain('Yes, import as monorepo')
expect(html).toContain('Yes, import as group')
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>No, import separately<\/button>/)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as monorepo<\/button>/)
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Yes, import as group<\/button>/)
})
it('maps the monorepo choice to grouped import and the non-monorepo choice to separate import', () => {
it('maps the group choice to grouped import and the separate choice to separate import', () => {
const onImport = vi.fn()
const host = document.createElement('div')
container = host
@@ -130,7 +130,7 @@ describe('AddRepoNestedImportStep', () => {
})
act(() => {
findButton(host, 'Yes, import as monorepo').click()
findButton(host, 'Yes, import as group').click()
findButton(host, 'No, import separately').click()
})
@@ -174,13 +174,11 @@ describe('AddRepoNestedImportStep', () => {
})
act(() => {
findButton(host, 'Yes, import as monorepo').click()
findButton(host, 'Yes, import as group').click()
})
expect(onImport).toHaveBeenCalledWith('group')
expect(
findButton(host, 'Yes, import as monorepo').querySelector('.animate-spin')
).not.toBeNull()
expect(findButton(host, 'Yes, import as group').querySelector('.animate-spin')).not.toBeNull()
expect(findButton(host, 'No, import separately').querySelector('.animate-spin')).toBeNull()
})
})
@@ -105,13 +105,13 @@ export function AddRepoNestedImportStep({
<p className="text-sm font-medium text-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.fb33359f69',
'Is this a monorepo?'
'Group these repositories?'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.d75170194e',
"Import them as a group if they're a monorepo or otherwise belong together. Orca will group them and let you work from the parent folder."
'Choose this if these projects belong together — a monorepo, or just a set of related repos. Orca will group them and let you work from the parent folder.'
)}
</p>
</div>
@@ -156,7 +156,7 @@ export function AddRepoNestedImportStep({
{showGroupSpinner ? <Loader2 className="size-3.5 animate-spin" /> : null}
{translate(
'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e',
'Import as group'
'Yes, import as group'
)}
</Button>
</div>
@@ -13,10 +13,12 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { searchRepos } from '@/lib/repo-search'
import { cn } from '@/lib/utils'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
@@ -37,6 +39,9 @@ const SidebarFilter = React.memo(function SidebarFilter({
}: SidebarFilterProps) {
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces)
// Surface the user-assigned shortcut here so the filter menu doubles as its
// discovery point ('Unassigned' until they bind one in Settings → Shortcuts).
const sleepingShortcut = useShortcutLabel('sidebar.sleepingWorkspaces.toggle')
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace)
const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces)
@@ -183,6 +188,7 @@ const SidebarFilter = React.memo(function SidebarFilter({
label={translate('auto.components.sidebar.SidebarFilter.638a2d221d', 'Hide sleeping')}
checked={!showSleepingWorkspaces}
onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)}
shortcutLabel={sleepingShortcut === 'Unassigned' ? undefined : sleepingShortcut}
/>
<FilterToggleRow
icon={<GitBranch className="size-3.5" />}
@@ -333,12 +339,14 @@ function FilterToggleRow({
icon,
label,
checked,
onChange
onChange,
shortcutLabel
}: {
icon: React.ReactNode
label: string
checked: boolean
onChange: (next: boolean) => void
shortcutLabel?: string
}) {
return (
<button
@@ -352,19 +360,22 @@ function FilterToggleRow({
<span className="text-muted-foreground">{icon}</span>
{label}
</span>
<span
aria-hidden
className={cn(
'relative h-3.5 w-6 shrink-0 rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted-foreground/30'
)}
>
<span className="inline-flex items-center gap-2">
{shortcutLabel ? <DropdownMenuShortcut>{shortcutLabel}</DropdownMenuShortcut> : null}
<span
aria-hidden
className={cn(
'absolute top-0.5 left-0.5 size-2.5 rounded-full bg-background shadow-sm transition-transform',
checked && 'translate-x-2.5'
'relative h-3.5 w-6 shrink-0 rounded-full transition-colors',
checked ? 'bg-primary' : 'bg-muted-foreground/30'
)}
/>
>
<span
className={cn(
'absolute top-0.5 left-0.5 size-2.5 rounded-full bg-background shadow-sm transition-transform',
checked && 'translate-x-2.5'
)}
/>
</span>
</span>
</button>
)
@@ -78,6 +78,7 @@ import {
ALL_GROUP_KEY,
PINNED_GROUP_KEY,
buildRows,
getProjectGroupHeaderKey,
getGroupKeysForWorktree,
getLineageGroupKey
} from './worktree-list-groups'
@@ -198,7 +199,7 @@ import {
parseExecutionHostId
} from '../../../../shared/execution-host'
import { getRepoHeaderCreateState } from './repo-header-create-state'
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
import type { PendingSidebarRowReveal, PendingSidebarWorktreeReveal } from '@/store/slices/ui'
import { getRepositoryIconSectionId } from '@/components/settings/repository-settings-targets'
import { keybindingMatchesAction } from '../../../../shared/keybindings'
import { ProjectGroupNameDialog } from './ProjectGroupNameDialog'
@@ -439,6 +440,130 @@ function revealMountedWorktreeElement(
return revealElementInScrollContainer(container, element, behavior) ? element : null
}
function revealMountedSidebarRowElement(
container: HTMLElement,
rowKey: string,
behavior: ScrollBehavior
): HTMLElement | null {
const element = document.getElementById(getWorktreeOptionId(rowKey))
if (!element || !container.contains(element)) {
return null
}
return revealElementInScrollContainer(container, element, behavior) ? element : null
}
function getRenderRowSidebarKey(row: RenderRow): string | null {
if (row.type === 'header') {
return row.key
}
if (row.type === 'item') {
return row.rowKey
}
if (row.type === 'folder-workspace') {
return folderWorkspaceKey(row.folderWorkspace.id)
}
if (row.type === 'pending-creation') {
return `pending:${row.creationId}`
}
if (row.type === 'imported-worktrees-card') {
return row.key
}
return null
}
function rowKeyMatchesRenderRow(row: RenderRow, rowKey: string): boolean {
if (row.type === 'lineage-group') {
return row.rows.some((item) => item.rowKey === rowKey)
}
return getRenderRowSidebarKey(row) === rowKey
}
function getProjectIdFromHeaderRowKey(rowKey: string): string | null {
if (!rowKey.startsWith('project:')) {
return null
}
const withoutPrefix = rowKey.slice('project:'.length)
const setupSeparator = withoutPrefix.indexOf('::setup:')
return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator)
}
function getRepoIdsFromHeaderRowKey(
rowKey: string,
repoMap: Map<string, Repo>,
projectGrouping?: ProjectGroupingModel
): string[] {
if (rowKey.startsWith('repo:')) {
return [rowKey.slice('repo:'.length)]
}
const setupMarker = '::setup:'
const setupIndex = rowKey.indexOf(setupMarker)
if (rowKey.startsWith('project:') && setupIndex !== -1) {
return [rowKey.slice(setupIndex + setupMarker.length)]
}
const projectId = getProjectIdFromHeaderRowKey(rowKey)
if (!projectId) {
return []
}
const repoIds = new Set<string>()
for (const setup of projectGrouping?.projectHostSetups ?? []) {
if (setup.projectId === projectId && repoMap.has(setup.repoId)) {
repoIds.add(setup.repoId)
}
}
const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId)
for (const repoId of project?.sourceRepoIds ?? []) {
if (repoMap.has(repoId)) {
repoIds.add(repoId)
}
}
return [...repoIds]
}
function getProjectGroupAncestorKeys(
projectGroupId: string | null | undefined,
projectGroups: readonly ProjectGroup[]
): string[] {
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
const keys: string[] = []
const seen = new Set<string>()
let currentGroupId = projectGroupId ?? null
while (currentGroupId && !seen.has(currentGroupId)) {
const group = groupsById.get(currentGroupId)
if (!group) {
break
}
seen.add(currentGroupId)
keys.unshift(getProjectGroupHeaderKey(group.id))
currentGroupId = group.parentGroupId
}
return keys
}
function getSidebarRowRevealAncestorKeys(args: {
rowKey: string
repoMap: Map<string, Repo>
projectGroups: readonly ProjectGroup[]
projectGrouping?: ProjectGroupingModel
}): string[] {
if (args.rowKey.startsWith('project-group:')) {
const groupId = args.rowKey.slice('project-group:'.length)
const group = args.projectGroups.find((candidate) => candidate.id === groupId)
return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups)
}
const keys = new Set<string>()
for (const repoId of getRepoIdsFromHeaderRowKey(
args.rowKey,
args.repoMap,
args.projectGrouping
)) {
const repo = args.repoMap.get(repoId)
for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) {
keys.add(key)
}
}
return [...keys]
}
function getWorktreeVisibilityMenuLabel(repo: Repo): string {
const visibility = effectiveExternalWorktreeVisibility(
repo,
@@ -471,7 +596,9 @@ type VirtualizedWorktreeViewportProps = {
handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void
activeModal: string
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
pendingRevealSidebarRow: PendingSidebarRowReveal | null
clearPendingRevealWorktreeId: () => void
clearPendingRevealSidebarRow: () => void
agentSendTargetWorktreeId: string | null
worktrees: Worktree[]
folderWorkspaces: readonly FolderWorkspace[]
@@ -1097,7 +1224,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
handleCreateFolderWorkspace,
activeModal,
pendingRevealWorktree,
pendingRevealSidebarRow,
clearPendingRevealWorktreeId,
clearPendingRevealSidebarRow,
agentSendTargetWorktreeId,
worktrees,
folderWorkspaces,
@@ -1143,9 +1272,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0)
const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0)
const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState<string | null>(
null
)
const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState<string | null>(null)
const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId)
const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent)
const worktreeDragSessionRef = useRef<WorktreeSidebarDragSession | null>(null)
@@ -1156,6 +1283,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const worktreeNativeAutoscrollLastFrameTimeRef = useRef<number | null>(null)
const worktreeNativeLatestPointRef = useRef<WorktreeSidebarDragPoint | null>(null)
const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null)
const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null)
const pendingRevealFrameIdsRef = useRef<Set<number>>(new Set())
const revealHighlightFrameIdRef = useRef<number | null>(null)
const revealHighlightTimeoutRef = useRef<number | null>(null)
@@ -1184,19 +1312,19 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
revealHighlightTimeoutRef.current = null
}
}, [])
const flashRevealedWorktree = useCallback(
(worktreeId: string) => {
const flashRevealedRow = useCallback(
(rowKey: string) => {
clearRevealHighlightTimeout()
clearRevealHighlightFrame()
// Why: remove before add restarts the CSS glow when the user repeatedly
// asks to reveal the same active workspace.
setHighlightedRevealWorktreeId(null)
setHighlightedRevealRowKey(null)
revealHighlightFrameIdRef.current = window.requestAnimationFrame(() => {
revealHighlightFrameIdRef.current = null
setHighlightedRevealWorktreeId(worktreeId)
setHighlightedRevealRowKey(rowKey)
revealHighlightTimeoutRef.current = window.setTimeout(() => {
revealHighlightTimeoutRef.current = null
setHighlightedRevealWorktreeId(null)
setHighlightedRevealRowKey(null)
}, 1500)
})
},
@@ -1867,7 +1995,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
: null
if (revealedOption) {
if (pendingRevealWorktree.highlight) {
flashRevealedWorktree(pendingRevealWorktree.worktreeId)
const revealedRowKey =
revealedOption.dataset.worktreeRowKey ?? getRenderRowSidebarKey(targetRow)
if (revealedRowKey) {
flashRevealedRow(revealedRowKey)
}
}
if (pendingRevealWorktree.beginRename) {
setRenamingWorktreeId({
@@ -1930,12 +2062,135 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
settings,
projectGroups,
pendingRevealRetryTick,
flashRevealedWorktree,
flashRevealedRow,
setRenamingWorktreeId,
schedulePendingRevealFrame,
cancelPendingRevealFrames
])
React.useEffect(() => {
if (!pendingRevealSidebarRow) {
return
}
const isProjectHeaderTarget =
pendingRevealSidebarRow.rowKey.startsWith('project-group:') ||
pendingRevealSidebarRow.rowKey.startsWith('project:') ||
pendingRevealSidebarRow.rowKey.startsWith('repo:')
if (isProjectHeaderTarget && groupBy !== 'repo') {
return
}
let toggledAncestor = false
for (const groupKey of getSidebarRowRevealAncestorKeys({
rowKey: pendingRevealSidebarRow.rowKey,
repoMap,
projectGroups,
projectGrouping
})) {
if (collapsedGroups.has(groupKey)) {
toggleGroup(groupKey)
toggledAncestor = true
}
}
if (toggledAncestor) {
return
}
let cancelled = false
const retryPendingReveal = () => {
const previousRetry = pendingRowRevealRetryRef.current
const nextRetryCount =
previousRetry?.rowKey === pendingRevealSidebarRow.rowKey ? previousRetry.count + 1 : 1
pendingRowRevealRetryRef.current = {
rowKey: pendingRevealSidebarRow.rowKey,
count: nextRetryCount
}
if (nextRetryCount <= 8) {
schedulePendingRevealFrame(() => {
if (!cancelled) {
setPendingRevealRetryTick((tick) => tick + 1)
}
})
return true
}
return false
}
schedulePendingRevealFrame(() => {
if (cancelled) {
return
}
const targetIndex = renderRows.findIndex((row) =>
rowKeyMatchesRenderRow(row, pendingRevealSidebarRow.rowKey)
)
if (targetIndex === -1) {
if (retryPendingReveal()) {
return
}
pendingRowRevealRetryRef.current = null
clearPendingRevealSidebarRow()
toast.error(
translate(
'auto.components.sidebar.WorktreeList.sidebarRowMissing',
'Target no longer exists'
)
)
return
}
const retryExactRevealOnNextFrame = () => {
if (retryPendingReveal()) {
return
}
pendingRowRevealRetryRef.current = null
clearPendingRevealSidebarRow()
}
const container = scrollRef.current
const revealedElement = container
? revealMountedSidebarRowElement(
container,
pendingRevealSidebarRow.rowKey,
pendingRevealSidebarRow.behavior
)
: null
if (revealedElement) {
if (pendingRevealSidebarRow.highlight) {
flashRevealedRow(pendingRevealSidebarRow.rowKey)
}
pendingRowRevealRetryRef.current = null
clearPendingRevealSidebarRow()
return
}
virtualizer.scrollToIndex(targetIndex, {
align: 'auto',
behavior: 'auto'
})
retryExactRevealOnNextFrame()
})
return () => {
cancelled = true
cancelPendingRevealFrames()
}
}, [
pendingRevealSidebarRow,
repoMap,
projectGroups,
projectGrouping,
collapsedGroups,
groupBy,
toggleGroup,
renderRows,
virtualizer,
pendingRevealRetryTick,
flashRevealedRow,
clearPendingRevealSidebarRow,
schedulePendingRevealFrame,
cancelPendingRevealFrames
])
const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache))
const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache))
const renderRowKeySignature = useMemo(
@@ -3742,6 +3997,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
>
<div
id={getWorktreeOptionId(row.key)}
role="button"
tabIndex={0}
aria-expanded={showHeaderCollapseAffordance ? !isHeaderCollapsed : undefined}
@@ -3754,6 +4010,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
className={cn(
'group relative flex h-7 w-full items-center gap-1.5 pr-2 text-left transition-all',
'cursor-pointer',
highlightedRevealRowKey === row.key &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
isDraggingThis &&
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
headerWorkspaceStatus &&
@@ -4308,7 +4566,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
'pointer-events-none opacity-0'
)}
data-scroll-reveal-highlight={
highlightedRevealWorktreeId === itemRow.worktree.id ? 'true' : undefined
highlightedRevealRowKey === itemRow.rowKey ? 'true' : undefined
}
// Why: nested child cards live inside the parent's clickable
// card body; bubbling would activate/edit the parent too.
@@ -4337,7 +4595,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
isActiveWorktree && !forceActiveSurface ? activeSurfaceVariant : 'primary'
}
isMultiSelected={selectedWorktreeIds.has(itemRow.worktree.id)}
revealHighlight={highlightedRevealWorktreeId === itemRow.worktree.id}
revealHighlight={highlightedRevealRowKey === itemRow.rowKey}
revealHighlightTone={revealHighlightTone}
selectedWorktrees={selectedWorktrees}
nativeDragEnabled={false}
@@ -4657,6 +4915,7 @@ const WorktreeList = React.memo(function WorktreeList({
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const currentSidebarWorktreeId = activeWorktreeId
const groupBy = useAppStore((s) => s.groupBy)
const setGroupBy = useAppStore((s) => s.setGroupBy)
const workspaceHostScope = useAppStore((s) => s.workspaceHostScope)
const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds)
const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder)
@@ -4679,9 +4938,12 @@ const WorktreeList = React.memo(function WorktreeList({
const activeView = useAppStore((s) => s.activeView)
const activeModal = useAppStore((s) => s.activeModal)
const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree)
const pendingRevealSidebarRow = useAppStore((s) => s.pendingRevealSidebarRow)
const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar)
const revealSidebarRow = useAppStore((s) => s.revealSidebarRow)
const setWorktreesPinnedAndReveal = useAppStore((s) => s.setWorktreesPinnedAndReveal)
const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId)
const clearPendingRevealSidebarRow = useAppStore((s) => s.clearPendingRevealSidebarRow)
const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode)
// Why: agent-send eligibility only matters while the picker is open. When it
// is closed, avoid subscribing WorktreeList to wake-time terminal layout churn.
@@ -5341,6 +5603,23 @@ const WorktreeList = React.memo(function WorktreeList({
workspaceHostScope
]
)
const renderedSidebarRowKeys = useMemo(() => {
const keys = new Set<string>()
for (const row of sectionRows) {
if (row.type === 'header') {
keys.add(row.key)
} else if (row.type === 'item') {
keys.add(row.rowKey)
} else if (row.type === 'folder-workspace') {
keys.add(folderWorkspaceKey(row.folderWorkspace.id))
} else if (row.type === 'pending-creation') {
keys.add(`pending:${row.creationId}`)
} else if (row.type === 'imported-worktrees-card') {
keys.add(row.key)
}
}
return keys
}, [sectionRows])
// Why: status headers change during wake (inactive -> active). Key only on
// the grouping mode so row identity survives those ordinary status moves.
const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all'
@@ -5878,14 +6157,16 @@ const WorktreeList = React.memo(function WorktreeList({
filterRepoIds,
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
visibleWorkspaceHostIds
visibleWorkspaceHostIds,
workspaceHostScope
}),
[
showSleepingWorkspaces,
filterRepoIds,
hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces,
visibleWorkspaceHostIds
visibleWorkspaceHostIds,
workspaceHostScope
]
)
const hasFilters = sidebarHasActiveFilters(filterState)
@@ -5923,12 +6204,48 @@ const WorktreeList = React.memo(function WorktreeList({
filterState
])
useEffect(() => {
if (!pendingRevealSidebarRow) {
return
}
const rowKey = pendingRevealSidebarRow.rowKey
const isProjectHeaderTarget =
rowKey.startsWith('project-group:') ||
rowKey.startsWith('project:') ||
rowKey.startsWith('repo:')
if (isProjectHeaderTarget && groupBy !== 'repo') {
setGroupBy('repo')
return
}
if (!renderedSidebarRowKeys.has(rowKey) && hasFilters) {
clearFilters()
}
}, [
clearFilters,
groupBy,
hasFilters,
pendingRevealSidebarRow,
renderedSidebarRowKeys,
setGroupBy
])
const handleRevealCurrentWorkspaceRequest = useCallback(
(event: Event) => {
const detail =
event instanceof CustomEvent
? (event.detail as ScrollToCurrentWorkspaceRevealRequestDetail | undefined)
: undefined
if (detail?.target?.type === 'sidebar-row') {
const sidebarDetail = detail as Extract<
ScrollToCurrentWorkspaceRevealRequestDetail,
{ target: { type: 'sidebar-row' } }
>
revealSidebarRow(detail.target.rowKey, {
behavior: 'smooth',
highlight: sidebarDetail.highlight !== false
})
return
}
if (!activeWorktreeId) {
return
}
@@ -5948,13 +6265,14 @@ const WorktreeList = React.memo(function WorktreeList({
revealWorktreeInSidebar(activeWorktreeId, {
behavior: 'smooth',
highlight: true,
beginRename: detail?.beginRename === true
beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true
})
},
[
activeWorktreeId,
clearFilters,
folderWorkspaces,
revealSidebarRow,
renderedWorktreeIds,
revealWorktreeInSidebar,
worktreeMap
@@ -6085,7 +6403,9 @@ const WorktreeList = React.memo(function WorktreeList({
handleCreateFolderWorkspace={handleCreateFolderWorkspace}
activeModal={activeModal}
pendingRevealWorktree={pendingRevealWorktree}
pendingRevealSidebarRow={pendingRevealSidebarRow}
clearPendingRevealWorktreeId={clearPendingRevealWorktreeId}
clearPendingRevealSidebarRow={clearPendingRevealSidebarRow}
agentSendTargetWorktreeId={agentSendTargetWorktreeId}
worktrees={worktrees}
folderWorkspaces={folderWorkspaces}
@@ -98,6 +98,7 @@ function filterState(overrides: Partial<FilterState> = {}): FilterState {
filterRepoIds: [],
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
workspaceHostScope: 'all',
...overrides
}
}
@@ -617,6 +618,16 @@ describe('computeClearFilterActions', () => {
expect(actions.resetFilterRepoIds).toBe(true)
})
it('flags legacy single-host scope for reset even without visible host ids', () => {
expect(computeClearFilterActions(filterState({ workspaceHostScope: 'ssh:host-1' }))).toEqual({
resetShowSleepingWorkspaces: false,
resetFilterRepoIds: false,
resetHideDefaultBranchWorkspace: false,
resetHideAutomationGeneratedWorkspaces: false,
resetVisibleWorkspaceHostIds: true
})
})
it('flags every active filter simultaneously', () => {
expect(
computeClearFilterActions(
@@ -36,6 +36,7 @@ export type SidebarFilterState = {
hideDefaultBranchWorkspace: boolean
hideAutomationGeneratedWorkspaces: boolean
visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null
workspaceHostScope?: ExecutionHostScope
}
/**
@@ -53,7 +54,8 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean {
state.filterRepoIds.length > 0 ||
state.hideDefaultBranchWorkspace ||
state.hideAutomationGeneratedWorkspaces ||
state.visibleWorkspaceHostIds != null
state.visibleWorkspaceHostIds != null ||
(state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE)
)
}
@@ -83,7 +85,9 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte
resetFilterRepoIds: state.filterRepoIds.length > 0,
resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace,
resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces,
resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null
resetVisibleWorkspaceHostIds:
state.visibleWorkspaceHostIds != null ||
(state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE)
}
}
@@ -145,14 +145,24 @@ type ProjectGroupingIndex = {
multiSetupProjectHostKeys: Set<string>
}
const projectGroupingIndexCache = new WeakMap<ProjectGroupingModel, ProjectGroupingIndex | null>()
function projectHostKey(projectId: string, hostId: string): string {
return `${projectId}::${hostId}`
}
function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null {
const projects = model?.projects ?? []
const projectHostSetups = model?.projectHostSetups ?? []
if (!model) {
return null
}
const cached = projectGroupingIndexCache.get(model)
if (cached !== undefined) {
return cached
}
const projects = model.projects ?? []
const projectHostSetups = model.projectHostSetups ?? []
if (projects.length === 0 || projectHostSetups.length === 0) {
projectGroupingIndexCache.set(model, null)
return null
}
const setupCountByProjectHost = new Map<string, number>()
@@ -166,18 +176,27 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin
multiSetupProjectHostKeys.add(key)
}
}
return {
const index = {
projectById: new Map(projects.map((project) => [project.id, project])),
setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])),
multiSetupProjectHostKeys
}
projectGroupingIndexCache.set(model, index)
return index
}
export type ProjectHeaderRevealTarget = {
key: string
label: string
repo?: Repo
projectId?: string
}
function getProjectGroupingForRepo(
repoId: string,
repoMap: Map<string, Repo>,
projectIndex: ProjectGroupingIndex | null
): { key: string; label: string; repo?: Repo; projectId?: string } {
): ProjectHeaderRevealTarget {
const repo = repoMap.get(repoId)
const setup = projectIndex?.setupByRepoId.get(repoId)
const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined
@@ -207,6 +226,14 @@ function getProjectGroupingForRepo(
}
}
export function getProjectHeaderRevealTarget(
repoId: string,
repoMap: Map<string, Repo>,
projectGrouping?: ProjectGroupingModel
): ProjectHeaderRevealTarget {
return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping))
}
function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void {
group.repoIds.add(repoId)
}
@@ -10,6 +10,8 @@ import type { TabDragItemData } from './useTabDragSplit'
import {
canDropTabForPaneColumnSplit,
canDropTabIntoPaneBody,
getTabDragActivationDistance,
TAB_DRAG_ACTIVATION_DISTANCE_PX,
useTabDragSplit
} from './useTabDragSplit'
@@ -177,6 +179,21 @@ afterEach(() => {
vi.clearAllMocks()
})
describe('tab drag activation distance', () => {
it('uses the named threshold for enabled tab drags', () => {
expect(TAB_DRAG_ACTIVATION_DISTANCE_PX).toBe(12)
expect(getTabDragActivationDistance(true)).toBe(TAB_DRAG_ACTIVATION_DISTANCE_PX)
})
it('keeps enabled tab drags above the old overly-sensitive distance', () => {
expect(TAB_DRAG_ACTIVATION_DISTANCE_PX).toBeGreaterThan(5)
})
it('uses an impossible activation distance when tab dragging is disabled', () => {
expect(getTabDragActivationDistance(false)).toBe(Number.MAX_SAFE_INTEGER)
})
})
describe('canDropTabIntoPaneBody', () => {
it('rejects pane-body drops that would split a single tab onto itself', () => {
expect(
@@ -45,6 +45,10 @@ export type { HoveredTabInsertion }
export type TabDropZone = 'center' | TabSplitDirection
// Why: tab activation waits for pointerup, so dnd-kit needs enough movement
// tolerance to avoid treating ordinary click jitter as an intentional drag.
export const TAB_DRAG_ACTIVATION_DISTANCE_PX = 12
export type TabDragItemData = {
kind: 'tab'
worktreeId: string
@@ -145,6 +149,10 @@ export function getTabPaneBodyDroppableId(groupId: string): UniqueIdentifier {
return `tab-group-pane-body:${groupId}`
}
export function getTabDragActivationDistance(enabled: boolean): number {
return enabled ? TAB_DRAG_ACTIVATION_DISTANCE_PX : Number.MAX_SAFE_INTEGER
}
export function useTabDragSplit({
worktreeId,
enabled = true
@@ -187,7 +195,7 @@ export function useTabDragSplit({
// the sensors array into a useEffect dependency list — changing its
// length between renders violates React's rules of hooks.
const pointerSensor = useSensor(PointerSensor, {
activationConstraint: { distance: enabled ? 5 : Number.MAX_SAFE_INTEGER }
activationConstraint: { distance: getTabDragActivationDistance(enabled) }
})
const sensors = useSensors(pointerSensor)
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
buildTaskPageGitHubCloseUpdate,
getTaskPageGitHubDuplicateCandidates,
getTaskPageGitHubDuplicateTargetErrorMessage,
validateTaskPageGitHubDuplicateTarget
} from './task-page-github-status-actions'
import type { GitHubWorkItem } from '../../../shared/types'
describe('TaskPage GitHub status actions', () => {
it('builds completed and not planned close updates', () => {
expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'completed' })).toEqual({
state: 'closed',
stateReason: 'completed'
})
expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'not_planned' })).toEqual({
state: 'closed',
stateReason: 'not_planned'
})
})
it('builds duplicate close updates with a target issue number', () => {
expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'duplicate', duplicateOf: 42 })).toEqual({
state: 'closed',
stateReason: 'duplicate',
duplicateOf: 42
})
})
it('validates duplicate targets before dispatch', () => {
expect(validateTaskPageGitHubDuplicateTarget('', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget('12', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget('12.5', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget('-1', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget('0', 12).ok).toBe(false)
expect(validateTaskPageGitHubDuplicateTarget(' 34 ', 12)).toEqual({
ok: true,
duplicateOf: 34
})
})
it('maps duplicate validation failures to localized messages', () => {
const t = (_key: string, fallback: string): string => fallback
const validation = validateTaskPageGitHubDuplicateTarget('same', 12)
expect(validation.ok).toBe(false)
if (!validation.ok) {
expect(getTaskPageGitHubDuplicateTargetErrorMessage(validation, t)).toBe(
'Use a whole issue number.'
)
}
})
it('filters duplicate candidates to other matching issues', () => {
const items = [
buildWorkItem({ number: 12, title: 'Current issue', type: 'issue' }),
buildWorkItem({ number: 34, title: 'Copy SSH file support', type: 'issue' }),
buildWorkItem({ number: 56, title: 'Unrelated pull request', type: 'pr' }),
buildWorkItem({ number: 78, title: 'Windows path copy bug', type: 'issue' })
]
expect(getTaskPageGitHubDuplicateCandidates(items, 12, '').map((item) => item.number)).toEqual([
34, 78
])
expect(
getTaskPageGitHubDuplicateCandidates(items, 12, 'copy').map((item) => item.number)
).toEqual([34, 78])
expect(
getTaskPageGitHubDuplicateCandidates(items, 12, '34').map((item) => item.number)
).toEqual([34])
})
})
function buildWorkItem(overrides: Partial<GitHubWorkItem>): GitHubWorkItem {
return {
id: `item-${overrides.number ?? 1}`,
type: 'issue',
number: 1,
title: 'Issue',
state: 'open',
url: 'https://github.com/stablyai/orca/issues/1',
labels: [],
updatedAt: '2026-06-22T00:00:00.000Z',
author: null,
repoId: 'repo-1',
...overrides
}
}

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