mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
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:
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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'")
|
||||
})
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14716,6 +14716,8 @@ describe('OrcaRuntimeService', () => {
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
displayName: 'foo',
|
||||
workspaceStatus: 'in-progress',
|
||||
sortOrder: 0,
|
||||
linkedIssue: 123,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -2763,6 +2763,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
path: '/tmp/worktree-a',
|
||||
branch: 'feature/foo',
|
||||
linkedIssue: 123,
|
||||
sortOrder: 0,
|
||||
unread: true,
|
||||
liveTerminalCount: 1,
|
||||
hasAttachedPty: true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+112
@@ -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
Reference in New Issue
Block a user