diff --git a/docs/github-tasks-close-reason-parity.md b/docs/github-tasks-close-reason-parity.md new file mode 100644 index 00000000000..dea16edfcec --- /dev/null +++ b/docs/github-tasks-close-reason-parity.md @@ -0,0 +1,137 @@ +# GitHub Tasks Close Reason Parity + +## Problem + +- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1025) renders `GHStatusCell` with only `Open` and `Closed`. +- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1064) sends `{ state: 'closed' }`, so the list path cannot choose GitHub close reasons. +- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1137) styles closed issues with rose/destructive color, while GitHub treats closing as a neutral/purple completion action rather than a delete/error action. +- [GitHubItemDialog.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/GitHubItemDialog.tsx:4960) has a separate issue-detail status popover; it must expose the same close reasons so opening an issue from Tasks does not fall back to the old Open/Closed-only UI. +- [GitHubIssueCommentComposer.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx:188) already supports close reasons in the detail composer, but the Tasks list does not. + +## Goal + +Bring the GitHub Tasks list status menu closer to GitHub: + +1. Closed issue UI must stop using destructive red styling. +2. Open issues must expose close-as-completed, close-as-not-planned, and close-as-duplicate from the status menu. +3. Duplicate closes must require a target issue number and pass it as `duplicateOf`. +4. Reopening still works from the same status menu. +5. GitHub issue detail metadata must use the same close-reason and duplicate-picker behavior as the Tasks row. + +## Non-goals + +- No provider-generic changes for GitLab, Linear, or Jira. +- No network-backed GitHub issue search picker in this pass; the duplicate picker uses the loaded Tasks cache plus an exact issue-number fallback. +- No schema changes or cache migration. +- No change to PR status behavior. +- No automatic cross-window broadcast beyond the existing cache/list refresh mechanics; this pass updates the current renderer optimistically and relies on normal refetch in other windows. + +## Design + +1. Add a small TaskPage GitHub status menu model in a named module, including option metadata, duplicate-target validation, and payload construction for close reasons. +2. Update `GHStatusCell` so `handleStateChange` accepts close options, optimistically patches only the issue `state`, and sends `stateReason` plus optional `duplicateOf`. +3. Route issue-state mutations by issue identity: + - If the row URL parses to `owner/repo`, use `github.project.updateIssueBySlug` / runtime `github.project.updateIssueBySlug`. This avoids closing the wrong repository for GitHub project/custom-source rows whose issue repo can differ from the caller repo. + - Otherwise keep the existing `github.updateIssue` path with `repoPath`/`repoId`/`sourceContext`. +4. Render the status popover with `Open`, `Close as completed`, `Close as not planned`, and `Close as duplicate` in both the Tasks row and issue-detail metadata sidebar. The duplicate row advances to a second-step picker with a back button, search field, loaded issue candidates, and an exact-number fallback. +5. Replace rose closed-state classes with token-based primary/ring color-mix classes so the closed pill is completion-like, not destructive. Keep the open-state green treatment for parity with the existing Tasks page unless a broader status-color pass changes it. +6. Keep the payload serializable for IPC/runtime/SSH: `{ state, stateReason, duplicateOf }` only contains strings and numbers. + +## API Notes + +- The local path in [issues.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/issues.ts:231) already closes issues with `gh issue close`. Preserve that model and pass: + - `--reason completed` for `stateReason: 'completed'` + - `--reason "not planned"` for `stateReason: 'not_planned'` + - `--duplicate-of ` for `stateReason: 'duplicate'` with `duplicateOf` +- The slug path in [mutations.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/project-view/mutations.ts:160) must not implement duplicates by PATCHing `duplicate_of` on `repos/{owner}/{repo}/issues/{n}`. `gh issue close --duplicate-of` is the supported CLI surface already used by the path-based mutation, so slug-addressed state changes should use `gh issue close/reopen --repo owner/repo` for state and reserve REST PATCH for title/body. +- When `stateReason: 'duplicate'` is sent without a valid `duplicateOf`, reject it before dispatch. Do not silently downgrade to plain closed or `--reason duplicate`, because GitHub's duplicate UX expects the target issue to be recorded. +- Keep `state_reason` REST PATCH only for non-duplicate close reasons if the implementation deliberately stays on REST for completed/not-planned. The simpler consistency target is to route all slug-addressed state changes through `gh issue close/reopen`. + +## Data Flow + +- User opens a GitHub issue status pill. +- Completed/not-planned click -> `handleStateChange('closed', { stateReason })`. +- Duplicate click -> second-step picker -> select a loaded issue or exact target number -> `handleStateChange('closed', { stateReason: 'duplicate', duplicateOf })`. +- Cell optimistically patches local state and store row state. +- Mutation goes through local IPC or runtime RPC with the same repo/source context. +- Failure reverts the local/store state and shows the existing error toast. +- Success records the existing `github-tasks` feature interaction and should invalidate or refresh the relevant work-items cache entry if the current filter can hide the row after the state changes (for example, an `is:open` filter). Without that, a closed row can remain visible until a later refetch. + +## Edge Cases + +- Duplicate target is blank, zero, negative, decimal, or the same issue number: keep the picker open and show inline validation after Enter. +- Duplicate target can be a different repository's issue only when the implementation accepts a URL; this pass takes a loaded same-repository issue or number, so validation and copy should describe it as an issue in the same repository. +- The row refreshes while the menu is open: reconcile the optimistic draft before paint as the current status cell already does. +- Rapid repeated choices: retain the existing request id guard so stale responses cannot revert a newer choice. +- SSH/runtime target: payload must remain serializable and use the existing runtime method. +- Multi-window consistency: do not assume another window's in-memory `workItemsCache` observes the optimistic patch. Ensure the mutation path updates GitHub authoritatively and that normal cache invalidation/refetch behavior eventually corrects other windows. +- Project/custom-source rows: prefer the parsed issue URL slug over the selected local repo when available, because a GitHub Project can contain issues from repositories other than the current worktree. +- PR rows and rows without a repo continue to render a non-editable status pill. +- Rows without a parsed slug and without a local repo/source context should stay non-editable; do not attempt a mutation from only an issue number. + +## Test Plan + +- Unit: menu model builds `{ state: 'closed', stateReason: 'completed' }`, `{ state: 'closed', stateReason: 'not_planned' }`, and duplicate payloads only for valid duplicate targets. +- Unit: duplicate validation rejects missing/self/non-positive/non-integer targets. +- Existing unit: optimistic status draft reconciliation remains unchanged. +- Main unit: `updateIssue` emits `gh issue close --reason completed`, `gh issue close --reason "not planned"`, `gh issue close --duplicate-of `, and `gh issue reopen`. +- Main unit: `updateIssueBySlug` uses `gh issue close/reopen --repo owner/repo` for state changes, rejects duplicate without `duplicateOf`, and does not send unsupported `duplicate_of` REST fields. +- Renderer unit: `GHStatusCell` uses slug-addressed mutation when `item.url` contains an owner/repo and falls back to `github.updateIssue` only when slug parsing is unavailable. +- Store/cache unit: successful close from an open-filtered Tasks list invalidates or refreshes the affected work-items cache so hidden-by-filter rows do not linger indefinitely. +- Electron validation: Tasks GitHub issue row status menu shows the new close options and closed issue pill is not red. +- Electron validation: duplicate close picker appears after the duplicate action and supports issue search/exact-number fallback. Do not submit a real close mutation against user data. +- Electron validation: GitHub issue detail status sidebar shows the same close options and duplicate picker as the list row. +- Electron/runtime smoke: same menu opens and validation works when the active runtime target is an SSH/environment source; avoid a real close mutation unless using disposable test data. + +## UI Quality Bar + +- Popover should feel like an action menu, not an error/destructive confirmation. +- Closed issue pill must be visually distinct from open but not red/destructive. +- Duplicate picker must fit in the popover at desktop and narrow widths without clipping. +- Hover, focus, disabled, and inline validation states use existing shadcn primitives/tokens. + +## Review Screenshots + +1. GitHub Tasks issue row with status popover open showing all close reasons. +2. Duplicate close second-step picker with search and issue candidates. +3. Closed issue row/pill showing non-red completion styling. +4. GitHub issue detail sidebar status popover showing all close reasons. +5. GitHub issue detail duplicate close second-step picker. +6. Adjacent smoke: GitHub Tasks header/search area still renders normally. + +## Rollout + +1. Add status menu model and tests. +2. Update `GHStatusCell` UI and mutation payload. +3. Run focused tests, typecheck, and lint. +4. Run UI quality review. +5. Validate in Electron and capture screenshots. + +## Lightweight Eng Review + +- Scope: kept to the GitHub Tasks status cell plus a small model module; no provider-wide or network-backed search-picker work. +- Architecture/data flow: preserves current renderer -> IPC/runtime mutation boundaries, but must route rows with parseable GitHub slugs through `updateIssueBySlug` so project/custom-source issues mutate the issue's own repository rather than the selected worktree repo. +- API feasibility: path-based mutations already use the supported `gh issue close --duplicate-of` CLI path; slug-addressed mutations need the same CLI close/reopen treatment instead of REST `duplicate_of`. +- Failure modes covered: + - Invalid duplicate targets stay local and do not mutate. + - Failed mutations revert optimistic state using the existing request id guard. + - Runtime/SSH remains on the existing serializable RPC method. + - Unsupported duplicate API shape is avoided by keeping duplicate close on `gh issue close --duplicate-of`. + - Rows filtered by open/closed status do not rely solely on optimistic patching; the relevant work-items cache must be invalidated/refetched after success. +- Test coverage required: + - Unit tests for close update payloads and duplicate validation. + - Main-process tests for both `updateIssue` and `updateIssueBySlug` close/reopen command generation. + - Renderer routing tests for slug-addressed versus repo-path-addressed issue rows. + - Cache invalidation/refetch coverage for status-filtered Tasks lists. + - Existing status draft tests for optimistic reconciliation. + - Electron screenshots for menu, duplicate validation, closed styling, and adjacent header smoke. +- Performance/blast radius: no material concern; one extra tiny model import and no new polling/IPC. +- UI quality bar: compact GitHub-like action menu, token-based non-destructive closed styling, no clipping or overlap. +- Required review screenshots: + 1. Status popover with close reasons. + 2. Duplicate second-step picker with search and issue candidates. + 3. Non-red closed status pill. + 4. Tasks header/search smoke. +- Residual risks: + - Duplicate target search is limited to loaded Tasks cache candidates, with exact-number fallback for unloaded same-repository issues. + - Other open Orca windows may show stale status until their normal GitHub Tasks data refresh runs. diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 1262ecd8e2f..c93ef44c03a 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -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('repo') + const [workspaceStatuses, setWorkspaceStatuses] = useState( + 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({ )} - {/* Worktree list */} {sections.length > 0 && ( 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 ( - toggleCollapsed(section.title)} - > + toggleCollapsed(section.key)}> {isCollapsed ? ( ) : ( @@ -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 */} setShowSortPicker(false)} /> - {/* Group picker modal */} setShowGroupPicker(false)} /> - {/* Filter modal — matches desktop's Status + Repositories dropdown */} setShowFilterModal(false)}> Filter diff --git a/mobile/scripts/mobile-lag-scenario.ts b/mobile/scripts/mobile-lag-scenario.ts index cd0aa2feedf..261596b9e11 100644 --- a/mobile/scripts/mobile-lag-scenario.ts +++ b/mobile/scripts/mobile-lag-scenario.ts @@ -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, diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index aa4d50fdc8d..1bb7835cfa7 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -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 = { status: WorktreeRollupStatus onPress: (item: T) => void onLongPress?: (item: T) => void + onToggleLineage?: (item: T) => void } export function WorktreeListRow({ @@ -62,16 +66,20 @@ export function WorktreeListRow({ hideRepo = false, status, onPress, - onLongPress + onLongPress, + onToggleLineage }: Props) { 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 ( [ styles.worktreeRow, + lineageDepth > 0 && { paddingLeft: spacing.lg + lineageDepth * 18 }, item.isActive && styles.worktreeRowActive, pressed && styles.worktreeRowPressed ]} @@ -133,6 +141,12 @@ export function WorktreeListRow({ /> + {lineageDepth > 0 && ( + + + Child + + )} {/* 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({ {item.agents && item.agents.length > 0 ? ( ) : null} + {lineageChildCount > 0 && onToggleLineage ? ( + { + event.stopPropagation() + onToggleLineage(item) + }} + > + {item.lineageCollapsed ? ( + + ) : ( + + )} + + + {lineageChildCount} {lineageChildCount === 1 ? 'child' : 'children'} + + + ) : null} {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, diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 9f8b886a711..a1672f3f91a 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -224,6 +224,7 @@ export const XTERM_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 = ` 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 = ` ? 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 diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts index da1d2095b5b..5fb78bb33ec 100644 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ b/mobile/src/terminal/terminal-webview-reflow.test.ts @@ -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 diff --git a/mobile/src/worktree/mobile-workspace-lineage.test.ts b/mobile/src/worktree/mobile-workspace-lineage.test.ts new file mode 100644 index 00000000000..f32219da431 --- /dev/null +++ b/mobile/src/worktree/mobile-workspace-lineage.test.ts @@ -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 { + 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']) + }) +}) diff --git a/mobile/src/worktree/mobile-workspace-lineage.ts b/mobile/src/worktree/mobile-workspace-lineage.ts new file mode 100644 index 00000000000..b28b7395928 --- /dev/null +++ b/mobile/src/worktree/mobile-workspace-lineage.ts @@ -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 = new Set() +): Worktree[] { + const visibleIds = new Set(worktrees.map((worktree) => worktree.worktreeId)) + const childrenByParentId = new Map() + const childIds = new Set() + + 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() + 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 +} diff --git a/mobile/src/worktree/mobile-workspace-statuses.ts b/mobile/src/worktree/mobile-workspace-statuses.ts new file mode 100644 index 00000000000..2c1322d8258 --- /dev/null +++ b/mobile/src/worktree/mobile-workspace-statuses.ts @@ -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)}` +} diff --git a/mobile/src/worktree/use-workspace-sections.ts b/mobile/src/worktree/use-workspace-sections.ts index 367fd3ec4eb..3337916a920 100644 --- a/mobile/src/worktree/use-workspace-sections.ts +++ b/mobile/src/worktree/use-workspace-sections.ts @@ -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 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] ) diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index bc5d3db8a4f..e6de6d8f466 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -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 { const worktreePath = join('/tmp', 'orca', 'worktrees', 'feature') @@ -25,6 +26,22 @@ function worktree(overrides: Partial = {}): Worktree { } } +function withoutSectionListKeys(sections: ReturnType) { + 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) }) }) diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index 6cd233d98b7..1691659e9f4 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -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 - hideSleeping: boolean - hideDefaultBranch: boolean +function makeSection( + key: string, + title: string, + data: Worktree[], + icon?: 'pin', + collapsedGroups?: ReadonlySet +): 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, 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[] = [ - '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 = { - 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): boolean { return w.isPinned || localPins.has(w.worktreeId) } @@ -221,33 +174,44 @@ export function buildSections( search: string, groupMode: MobileGroupMode, pinnedIds: Set, - repoIdsByName: ReadonlyMap = new Map() + repoIdsByName: ReadonlyMap = new Map(), + workspaceStatuses: readonly WorkspaceStatusDefinition[] = DEFAULT_MOBILE_WORKSPACE_STATUSES, + collapsedGroups: ReadonlySet = 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() - 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, Worktree[]>() - for (const w of unpinned) { - const key = getWorktreeStatus(w) + const renderableWorkspaceStatuses = coerceMobileWorkspaceStatuses(workspaceStatuses) + const byStatus = new Map() + 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() - for (const w of unpinned) { + const byGroup = new Map() + 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 + ) + ) } } } diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts new file mode 100644 index 00000000000..8418b2753ad --- /dev/null +++ b/mobile/src/worktree/workspace-list-types.ts @@ -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 + hideSleeping: boolean + hideDefaultBranch: boolean +} + +export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] } diff --git a/mobile/src/worktree/workspace-pr-status-groups.ts b/mobile/src/worktree/workspace-pr-status-groups.ts new file mode 100644 index 00000000000..998e1d4be1d --- /dev/null +++ b/mobile/src/worktree/workspace-pr-status-groups.ts @@ -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 = { + 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' +} diff --git a/mobile/src/worktree/workspace-view-settings.test.ts b/mobile/src/worktree/workspace-view-settings.test.ts index 08186288d7a..d6cef4448e6 100644 --- a/mobile/src/worktree/workspace-view-settings.test.ts +++ b/mobile/src/worktree/workspace-view-settings.test.ts @@ -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, diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index a15def9ebcc..179fd8becd9 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -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> = { @@ -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 diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index 56b31c77aca..339d5d1c9a3 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -29,6 +29,7 @@ function worktree(overrides: Partial = {}): 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' })] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index 57494d496df..c851bda24d9 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -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 diff --git a/package.json b/package.json index 6b08ed1e086..8cb01bd38bf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 43c08542c60..d316c869d9d 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -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'") }) diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index a533fb0ed1a..c4c112f8ead 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -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 { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/src/main/agent-hooks/managed-hook-timeout.test.ts b/src/main/agent-hooks/managed-hook-timeout.test.ts new file mode 100644 index 00000000000..6ff32c7a21d --- /dev/null +++ b/src/main/agent-hooks/managed-hook-timeout.test.ts @@ -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() + 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 + dirs: Set + modes: Map +} + +function createFakeSftp(initialFiles: Record = {}): { + 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( + (sum, child) => sum + countManagedCarriersWithTimeout(child, expectedTimeout), + 0 + ) + } + if (node === null || typeof node !== 'object') { + return 0 + } + const record = node as Record + 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((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) + } + ) + }) +}) diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index e78d4cf5f59..343c4d21552 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -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( diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index fc94d6e2c6a..594cb7e332c 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -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] } diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 94f365a213a..cc72c4c3085 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -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 + } + 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 }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 99638a787bc..15c415f94c0 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -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 }) } diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index a5a34a30512..64413bdd7c1 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -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] } diff --git a/src/main/crash-reporting/process-gone-classification.test.ts b/src/main/crash-reporting/process-gone-classification.test.ts index 6c40c952b1d..431da702ece 100644 --- a/src/main/crash-reporting/process-gone-classification.test.ts +++ b/src/main/crash-reporting/process-gone-classification.test.ts @@ -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({ diff --git a/src/main/crash-reporting/process-gone-classification.ts b/src/main/crash-reporting/process-gone-classification.ts index d031cbb048a..07ac1b7ef4b 100644 --- a/src/main/crash-reporting/process-gone-classification.ts +++ b/src/main/crash-reporting/process-gone-classification.ts @@ -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']) diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts index 521e543d0fa..6beebfd4926 100644 --- a/src/main/crash-reporting/process-gone-dedupe.test.ts +++ b/src/main/crash-reporting/process-gone-dedupe.test.ts @@ -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) + }) }) diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts index f2caf0f3d1f..eb746a18e57 100644 --- a/src/main/crash-reporting/process-gone-dedupe.ts +++ b/src/main/crash-reporting/process-gone-dedupe.ts @@ -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() diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index fb57ce1b8fd..1eb2a2f4e8f 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -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] } diff --git a/src/main/devin/hook-settings.ts b/src/main/devin/hook-settings.ts index 62cb4bd259c..873836897d9 100644 --- a/src/main/devin/hook-settings.ts +++ b/src/main/devin/hook-settings.ts @@ -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] } diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index f8e07cec3ab..ce61c9a010b 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -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] } diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 889e7a467f8..d724ced28f1 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -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] } diff --git a/src/main/git/status-upstream-negative-cache.test.ts b/src/main/git/status-upstream-negative-cache.test.ts new file mode 100644 index 00000000000..0dcc4f4bcc6 --- /dev/null +++ b/src/main/git/status-upstream-negative-cache.test.ts @@ -0,0 +1,329 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { existsSyncMock, gitExecFileAsyncMock, readFileMock } = vi.hoisted(() => ({ + existsSyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitStreamStdout: async ( + args: string[], + options: { onStdout: (chunk: string) => boolean | void } + ) => { + const { stdout } = await gitExecFileAsyncMock(args) + const stoppedEarly = options.onStdout(stdout ?? '') === true + return { stoppedEarly } + }, + gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => ({ + ...env, + GIT_OPTIONAL_LOCKS: '0' + }) +})) + +vi.mock('fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('fs', () => ({ + existsSync: existsSyncMock +})) + +import { + clearEffectiveUpstreamNegativeStatusCache, + clearEffectiveUpstreamStatusCacheForTests, + getEffectiveUpstreamStatusCacheCountForTests, + getEffectiveUpstreamStatusGenerationCountForTests, + getStatus +} from './status' + +describe('local upstream negative cache', () => { + beforeEach(() => { + clearEffectiveUpstreamStatusCacheForTests() + existsSyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + readFileMock.mockReset() + existsSyncMock.mockReturnValue(false) + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + }) + + it('bypasses a cached negative result for strict status reads', async () => { + let originBranchExists = false + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + throw new Error('missing remote branch') + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const first = await getStatus('/repo') + originBranchExists = true + const automatic = await getStatus('/repo') + const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + + expect(first.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(automatic.upstreamStatus).toEqual(first.upstreamStatus) + expect(strict.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + }) + + it('keeps an older automatic negative probe from overwriting a strict positive result', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head feature\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const automatic = getStatus('/repo') + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + const staleAutomatic = await automatic + const nextAutomatic = await getStatus('/repo') + + expect(strict.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(staleAutomatic.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus) + }) + + it('does not trim generation for an unresolved automatic probe', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const branchQueue = [ + 'feature', + 'feature', + ...Array.from({ length: 512 }, (_, index) => `other-${index}`), + 'feature' + ] + let currentBranch = 'feature' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = branchQueue.shift() ?? currentBranch + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const automatic = getStatus('/repo') + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + for (let index = 0; index < 512; index += 1) { + await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await getStatus('/repo') + + expect(strict.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(nextAutomatic.upstreamStatus).toEqual(strict.upstreamStatus) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('does not trim generation for a cleared automatic probe before it settles', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const branchQueue = [ + 'feature', + ...Array.from({ length: 512 }, (_, index) => `other-${index}`), + 'feature' + ] + let currentBranch = 'feature' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = branchQueue.shift() ?? currentBranch + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.some((arg) => arg.startsWith('HEAD...origin/'))) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const automatic = getStatus('/repo') + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + clearEffectiveUpstreamNegativeStatusCache({ worktreePath: '/repo', branchName: 'feature' }) + for (let index = 0; index < 512; index += 1) { + await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await getStatus('/repo') + + expect(nextAutomatic.upstreamStatus).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds effective-upstream negative entries', async () => { + let branchIndex = 0 + let currentBranch = 'feature-0' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = `feature-${branchIndex}` + branchIndex += 1 + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.some((arg) => arg.startsWith('refs/remotes/origin/'))) { + throw new Error('missing remote branch') + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + for (let index = 0; index < 513; index += 1) { + await getStatus('/repo') + } + + expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(512) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds write-generation entries from positive strict probes', async () => { + let branchIndex = 0 + let currentBranch = 'feature-0' + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + currentBranch = `feature-${branchIndex}` + branchIndex += 1 + return { + stdout: `# branch.oid abcdef1234567890\n# branch.head ${currentBranch}\n` + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: `${currentBranch}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${currentBranch}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${currentBranch}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${currentBranch}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + for (let index = 0; index < 513; index += 1) { + await getStatus('/repo', { bypassEffectiveUpstreamNegativeCache: true }) + } + + expect(getEffectiveUpstreamStatusCacheCountForTests()).toBe(0) + expect(getEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) + }) +}) diff --git a/src/main/git/status-upstream-probe-churn.test.ts b/src/main/git/status-upstream-probe-churn.test.ts index b59f88dee8a..ddf216b3f9d 100644 --- a/src/main/git/status-upstream-probe-churn.test.ts +++ b/src/main/git/status-upstream-probe-churn.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Repro command: // pnpm exec vitest run --config config/vitest.config.ts src/main/git/status-upstream-probe-churn.test.ts -t "missing-upstream polling churn" @@ -51,6 +51,10 @@ describe('getStatus missing-upstream polling churn', () => { readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/Initi-Project\n') }) + afterEach(() => { + vi.useRealTimers() + }) + it('does not repeat failed effective-upstream probes for a branch with no upstream', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args.includes('status')) { @@ -87,6 +91,39 @@ describe('getStatus missing-upstream polling churn', () => { expect(sameNameOriginProbeCalls).toHaveLength(1) }) + it('keeps failed effective-upstream probes cached beyond thirty seconds', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head Initi-Project\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'Initi-Project\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error("fatal: no upstream configured for branch 'Initi-Project'") + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/Initi-Project')) { + throw new Error('missing remote branch') + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + await getStatus('/repo') + vi.setSystemTime(31_000) + await getStatus('/repo') + + const upstreamProbeCalls = gitExecFileAsyncMock.mock.calls.filter((call) => { + const args = getGitArgs(call) + return args[0] === 'rev-parse' && args.includes('HEAD@{u}') + }) + + expect(upstreamProbeCalls).toHaveLength(1) + }) + it('coalesces concurrent effective-upstream probes for a branch with no upstream', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args.includes('status')) { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 4097adaf1e6..8876fb18f53 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -52,7 +52,8 @@ import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024 const MAX_STAGED_COMMIT_CONTEXT_BYTES = MAX_GIT_SHOW_BYTES const BULK_CHUNK_SIZE = 100 -const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 30_000 +const EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS = 5 * 60_000 +const MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES = 512 type EffectiveUpstreamStatusCacheEntry = { expiresAt: number @@ -61,10 +62,22 @@ type EffectiveUpstreamStatusCacheEntry = { const effectiveUpstreamStatusCache = new Map() const effectiveUpstreamStatusInFlight = new Map>() +const retiredEffectiveUpstreamStatusInFlight = new Map>() +const effectiveUpstreamStatusWriteGeneration = new Map() export function clearEffectiveUpstreamStatusCacheForTests(): void { effectiveUpstreamStatusCache.clear() effectiveUpstreamStatusInFlight.clear() + retiredEffectiveUpstreamStatusInFlight.clear() + effectiveUpstreamStatusWriteGeneration.clear() +} + +export function getEffectiveUpstreamStatusCacheCountForTests(): number { + return effectiveUpstreamStatusCache.size +} + +export function getEffectiveUpstreamStatusGenerationCountForTests(): number { + return effectiveUpstreamStatusWriteGeneration.size } export type GetStatusOptions = GitRuntimeOptions & { @@ -74,6 +87,7 @@ export type GetStatusOptions = GitRuntimeOptions & { * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. */ limit?: number + bypassEffectiveUpstreamNegativeCache?: boolean } /** @@ -168,7 +182,8 @@ export async function getStatus( cacheKey, worktreePath, branchName, - options + options, + options.bypassEffectiveUpstreamNegativeCache === true ) } catch { // Why: git status polling should not fail just because the richer @@ -282,6 +297,64 @@ function getEffectiveUpstreamStatusCacheKey( return [worktreePath, options.wslDistro ?? 'host', branchName, upstreamName ?? ''].join('\0') } +export function clearEffectiveUpstreamNegativeStatusCache(identity: { + worktreePath: string + branchName: string + upstreamName?: string + options?: GitRuntimeOptions +}): void { + const cacheKey = getEffectiveUpstreamStatusCacheKey( + identity.worktreePath, + identity.branchName, + identity.upstreamName, + identity.options + ) + retireEffectiveUpstreamStatusProbe(cacheKey) + effectiveUpstreamStatusCache.delete(cacheKey) + effectiveUpstreamStatusInFlight.delete(cacheKey) + effectiveUpstreamStatusWriteGeneration.set( + cacheKey, + (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 + ) +} + +function retireEffectiveUpstreamStatusProbe(cacheKey: string): void { + const retiredProbe = effectiveUpstreamStatusInFlight.get(cacheKey) + if (!retiredProbe) { + return + } + retiredEffectiveUpstreamStatusInFlight.set(cacheKey, retiredProbe) + void retiredProbe + .finally(() => { + if (retiredEffectiveUpstreamStatusInFlight.get(cacheKey) === retiredProbe) { + retiredEffectiveUpstreamStatusInFlight.delete(cacheKey) + trimEffectiveUpstreamStatusGeneration() + } + }) + .catch(() => undefined) +} + +function hasPendingEffectiveUpstreamStatusProbe(cacheKey: string): boolean { + return ( + effectiveUpstreamStatusInFlight.has(cacheKey) || + retiredEffectiveUpstreamStatusInFlight.has(cacheKey) + ) +} + +function trimEffectiveUpstreamStatusGeneration(): void { + for (const cacheKey of effectiveUpstreamStatusWriteGeneration.keys()) { + if ( + effectiveUpstreamStatusWriteGeneration.size <= MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES + ) { + break + } + if (hasPendingEffectiveUpstreamStatusProbe(cacheKey)) { + continue + } + effectiveUpstreamStatusWriteGeneration.delete(cacheKey) + } +} + function readCachedEffectiveUpstreamStatus( cacheKey: string, now: number @@ -301,12 +374,18 @@ function rememberEffectiveUpstreamStatus( cacheKey: string, status: GitUpstreamStatus, now: number, - probedSameNameOriginRef: boolean + probedSameNameOriginRef: boolean, + writeGeneration: number ): void { // Why: hasConfiguredPushTarget gates a write action. Re-probe it each poll // rather than keeping a stale positive target after branch config changes. if (status.hasUpstream || status.hasConfiguredPushTarget) { effectiveUpstreamStatusCache.delete(cacheKey) + effectiveUpstreamStatusWriteGeneration.set(cacheKey, writeGeneration + 1) + trimEffectiveUpstreamStatusGeneration() + return + } + if ((effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) !== writeGeneration) { return } if (!probedSameNameOriginRef) { @@ -318,41 +397,58 @@ function rememberEffectiveUpstreamStatus( status, expiresAt: now + EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_TTL_MS }) + while (effectiveUpstreamStatusCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { + const oldest = effectiveUpstreamStatusCache.keys().next() + if (oldest.done) { + break + } + effectiveUpstreamStatusCache.delete(oldest.value) + effectiveUpstreamStatusWriteGeneration.delete(oldest.value) + } + trimEffectiveUpstreamStatusGeneration() } async function readOrProbeEffectiveUpstreamStatus( cacheKey: string, worktreePath: string, branchName: string, - options: GitRuntimeOptions = {} + options: GitRuntimeOptions = {}, + bypassCache = false ): Promise { - const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) - if (cached) { - return cached - } + if (!bypassCache) { + const cached = readCachedEffectiveUpstreamStatus(cacheKey, Date.now()) + if (cached) { + return cached + } - const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey) - if (inFlight) { - return inFlight + const inFlight = effectiveUpstreamStatusInFlight.get(cacheKey) + if (inFlight) { + return inFlight + } } // Why: source-control mount and root git refresh can overlap during startup. // Coalesce the richer upstream probe so a stable missing ref fails once. + const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 const probe = probeEffectiveUpstreamStatus(worktreePath, branchName, options).then((result) => { rememberEffectiveUpstreamStatus( cacheKey, result.status, Date.now(), - result.probedSameNameOriginRef + result.probedSameNameOriginRef, + writeGeneration ) return result.status }) - effectiveUpstreamStatusInFlight.set(cacheKey, probe) + if (!bypassCache) { + effectiveUpstreamStatusInFlight.set(cacheKey, probe) + } try { return await probe } finally { if (effectiveUpstreamStatusInFlight.get(cacheKey) === probe) { effectiveUpstreamStatusInFlight.delete(cacheKey) + trimEffectiveUpstreamStatusGeneration() } } } diff --git a/src/main/github/issues.test.ts b/src/main/github/issues.test.ts index 1c8e5c5596f..f68726b395a 100644 --- a/src/main/github/issues.test.ts +++ b/src/main/github/issues.test.ts @@ -251,4 +251,52 @@ describe('issue source operations', () => { { cwd: '/repo-root' } ) }) + + it('closes issues with completed, not planned, and duplicate reasons', async () => { + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await expect( + updateIssue('/repo-root', 924, { state: 'closed', stateReason: 'completed' }) + ).resolves.toEqual({ ok: true }) + await expect( + updateIssue('/repo-root', 925, { state: 'closed', stateReason: 'not_planned' }) + ).resolves.toEqual({ ok: true }) + await expect( + updateIssue('/repo-root', 926, { + state: 'closed', + stateReason: 'duplicate', + duplicateOf: 99 + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['issue', 'close', '924', '--repo', 'stablyai/orca', '--reason', 'completed'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['issue', 'close', '925', '--repo', 'stablyai/orca', '--reason', 'not planned'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['issue', 'close', '926', '--repo', 'stablyai/orca', '--duplicate-of', '99'], + { cwd: '/repo-root' } + ) + }) + + it('reopens issues through gh issue reopen', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) + + await expect(updateIssue('/repo-root', 924, { state: 'open' })).resolves.toEqual({ + ok: true + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'reopen', '924', '--repo', 'stablyai/orca'], + { cwd: '/repo-root' } + ) + }) }) diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 8af4ee10b4b..7dbb30f30d6 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -12,6 +12,7 @@ import type { } from '../../shared/types' import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' import { getRateLimit, noteRateLimitSpend, rateLimitGuard } from './rate-limit' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' type QueueEntry = { key: string @@ -64,6 +65,7 @@ const BACKGROUND_BUDGET_MAX = 20 const POST_PUSH_DELAY_MS = 2_500 const BACKOFF_BASE_MS = 60_000 const BACKOFF_MAX_MS = 15 * 60_000 +const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 let sequence = 0 let draining = false @@ -74,6 +76,12 @@ const errorBackoff = new Map() let lastBackgroundStartAt = 0 const visibleByWindow = new Map }>() let outcomeObserver: PRRefreshOutcomeObserver | null = null +const diagnosticsCounters = { + enqueued: 0, + coalesced: 0, + skipped: 0, + backgroundPauses: 0 +} export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { outcomeObserver = observer @@ -94,6 +102,27 @@ function removeInvisibleVisibleRefreshes(): void { } } +function recordPRRefreshQueueDiagnostic( + event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', + reason: GitHubPRRefreshReason, + skippedReason?: GitHubPRRefreshSkippedReason +): void { + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_queue', + coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, + minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, + data: { + event, + reason, + ...(skippedReason ? { skippedReason } : {}), + enqueued: diagnosticsCounters.enqueued, + coalesced: diagnosticsCounters.coalesced, + skipped: diagnosticsCounters.skipped, + backgroundPauses: diagnosticsCounters.backgroundPauses + } + }) +} + export function clearVisiblePRRefreshWindow(windowId: number): void { if (!visibleByWindow.delete(windowId)) { return @@ -480,6 +509,8 @@ async function drainQueue(): Promise { const budgetDelay = isBudgetedQueueEntry(next) ? nextBudgetDelay() : 0 if (budgetDelay > 0) { + diagnosticsCounters.backgroundPauses += 1 + recordPRRefreshQueueDiagnostic('background-pause', next.reason) scheduleDrain(budgetDelay) return } @@ -488,6 +519,8 @@ async function drainQueue(): Promise { const aliases = Array.from(next.aliases.values()) const skippedReason = validateCandidate(next.candidate) if (skippedReason) { + diagnosticsCounters.skipped += 1 + recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason) broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) continue } @@ -578,6 +611,8 @@ export function enqueuePRRefresh( const skippedReason = validateCandidate(candidate) if (skippedReason) { removeQueuedAliasForInvalidCandidate(key, alias) + diagnosticsCounters.skipped += 1 + recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason) broadcast({ aliases: [alias], reason, @@ -592,6 +627,8 @@ export function enqueuePRRefresh( const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) if (existing) { existing.aliases.set(alias.cacheKey, alias) + diagnosticsCounters.coalesced += 1 + recordPRRefreshQueueDiagnostic('coalesced', reason) const shouldPromoteExisting = priority > existing.priority || isManual(reason) || @@ -604,6 +641,8 @@ export function enqueuePRRefresh( existing.windowId = windowId ?? existing.windowId } } else { + diagnosticsCounters.enqueued += 1 + recordPRRefreshQueueDiagnostic('enqueued', reason) queue.set(key, { key, candidate, diff --git a/src/main/github/pr-refresh-validation-backoff.test.ts b/src/main/github/pr-refresh-validation-backoff.test.ts new file mode 100644 index 00000000000..fd4211783ec --- /dev/null +++ b/src/main/github/pr-refresh-validation-backoff.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { recordCoalescedCrashBreadcrumbMock } = vi.hoisted(() => ({ + recordCoalescedCrashBreadcrumbMock: vi.fn() +})) + +vi.mock('../crash-reporting/crash-breadcrumb-store', () => ({ + recordCoalescedCrashBreadcrumb: recordCoalescedCrashBreadcrumbMock +})) + +import { + clearPRRefreshValidationBackoffForTests, + getPRRefreshValidationBackoffCountForTests, + notePRRefreshValidationDenial +} from './pr-refresh-validation-backoff' + +describe('PR refresh validation backoff', () => { + beforeEach(() => { + clearPRRefreshValidationBackoffForTests() + recordCoalescedCrashBreadcrumbMock.mockReset() + }) + + it('backs off repeated automatic validation denials until the TTL expires', () => { + const identity = { + repoId: 'repo-1', + repoPath: '/workspace/missing', + reason: 'unknown-repo' as const + } + + expect(notePRRefreshValidationDenial(identity, 0)).toBe('validation-denied') + expect(notePRRefreshValidationDenial(identity, 60_000)).toBe('validation-backoff') + expect(notePRRefreshValidationDenial(identity, 5 * 60_000 + 1)).toBe('validation-denied') + }) + + it('bounds validation backoff entries', () => { + for (let index = 0; index < 257; index += 1) { + notePRRefreshValidationDenial( + { + repoId: `repo-${index}`, + repoPath: `/workspace/missing-${index}`, + reason: 'unknown-repo' + }, + index + ) + } + + expect(getPRRefreshValidationBackoffCountForTests()).toBe(256) + }) + + it('records path-safe diagnostic breadcrumbs', () => { + notePRRefreshValidationDenial( + { + repoId: 'repo-1', + repoPath: '/Users/alice/private/project', + reason: 'repo-path-mismatch' + }, + 0 + ) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'pr_refresh_validation_skip', + data: expect.objectContaining({ + reason: 'repo-path-mismatch', + result: 'recorded', + token: expect.any(String) + }) + }) + ) + const payload = recordCoalescedCrashBreadcrumbMock.mock.calls[0]?.[0] + expect(JSON.stringify(payload)).not.toContain('/Users/alice/private/project') + }) +}) diff --git a/src/main/github/pr-refresh-validation-backoff.ts b/src/main/github/pr-refresh-validation-backoff.ts new file mode 100644 index 00000000000..ab03a064d84 --- /dev/null +++ b/src/main/github/pr-refresh-validation-backoff.ts @@ -0,0 +1,107 @@ +import { createHash } from 'crypto' +import { resolve } from 'path' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' + +const VALIDATION_BACKOFF_TTL_MS = 5 * 60_000 +const MAX_VALIDATION_BACKOFF_ENTRIES = 256 +const VALIDATION_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export type PRRefreshValidationDenialReason = + | 'unknown-repo' + | 'repo-path-mismatch' + | 'host-mismatch' + +type ValidationBackoffIdentity = { + repoId?: string | null + repoPath: string + reason: PRRefreshValidationDenialReason +} + +type ValidationBackoffEntry = { + expiresAt: number +} + +type ValidationBackoffCounters = { + recorded: number + skipped: number + expired: number +} + +const validationBackoff = new Map() +const counters: ValidationBackoffCounters = { + recorded: 0, + skipped: 0, + expired: 0 +} + +function validationIdentityKey(identity: ValidationBackoffIdentity): string { + return [identity.repoId ?? '', resolve(identity.repoPath), identity.reason].join('\0') +} + +function validationIdentityToken(key: string): string { + return createHash('sha256').update(key).digest('hex').slice(0, 12) +} + +function evictOldestValidationBackoffEntries(): void { + while (validationBackoff.size > MAX_VALIDATION_BACKOFF_ENTRIES) { + const oldest = validationBackoff.keys().next() + if (oldest.done) { + break + } + validationBackoff.delete(oldest.value) + } +} + +function recordValidationBreadcrumb( + reason: PRRefreshValidationDenialReason, + result: 'recorded' | 'backoff', + token: string +): void { + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_validation_skip', + coalesceKey: `pr-refresh-validation:${reason}:${token}`, + minIntervalMs: VALIDATION_BREADCRUMB_MIN_INTERVAL_MS, + data: { + reason, + result, + token, + recorded: counters.recorded, + skipped: counters.skipped, + expired: counters.expired + } + }) +} + +export function notePRRefreshValidationDenial( + identity: ValidationBackoffIdentity, + nowMs = Date.now() +): 'validation-denied' | 'validation-backoff' { + const key = validationIdentityKey(identity) + const existing = validationBackoff.get(key) + const token = validationIdentityToken(key) + if (existing && existing.expiresAt > nowMs) { + counters.skipped += 1 + recordValidationBreadcrumb(identity.reason, 'backoff', token) + return 'validation-backoff' + } + if (existing) { + counters.expired += 1 + validationBackoff.delete(key) + } + counters.recorded += 1 + validationBackoff.set(key, { expiresAt: nowMs + VALIDATION_BACKOFF_TTL_MS }) + evictOldestValidationBackoffEntries() + recordValidationBreadcrumb(identity.reason, 'recorded', token) + return 'validation-denied' +} + +export function clearPRRefreshValidationBackoffForTests(): void { + validationBackoff.clear() + counters.recorded = 0 + counters.skipped = 0 + counters.expired = 0 +} + +export function getPRRefreshValidationBackoffCountForTests(): number { + return validationBackoff.size +} diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts new file mode 100644 index 00000000000..9d8cabca7f8 --- /dev/null +++ b/src/main/github/project-view/mutations.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { acquireMock, ghExecFileAsyncMock, releaseMock, runRestMock } = vi.hoisted(() => ({ + acquireMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + releaseMock: vi.fn(), + runRestMock: vi.fn() +})) + +vi.mock('./internals', () => ({ + acquire: acquireMock, + release: releaseMock, + extractExecError: (err: unknown) => ({ + stderr: err instanceof Error ? err.message : String(err), + stdout: '' + }), + ghExecFileAsync: ghExecFileAsyncMock, + rateLimitGuard: () => ({ blocked: false }), + noteRateLimitSpend: vi.fn(), + classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }), + rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }), + runGraphql: vi.fn(), + runRest: runRestMock, + validateSlugArgs: (owner: string, repo: string) => + owner && repo ? { ok: true } : { ok: false, error: { type: 'validation_error' } }, + assertPositiveInt: (value: number, name: string) => + Number.isInteger(value) && value > 0 + ? { ok: true, value } + : { ok: false, error: { type: 'validation_error', message: `${name} invalid` } } +})) + +import { updateIssueBySlug } from './mutations' + +describe('updateIssueBySlug', () => { + beforeEach(() => { + acquireMock.mockReset().mockResolvedValue(undefined) + ghExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' }) + releaseMock.mockReset() + runRestMock.mockReset().mockResolvedValue({ ok: true, data: {} }) + }) + + it('closes slug-addressed issues with completed and not planned reasons via gh issue close', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'closed', stateReason: 'completed' } + }) + ).resolves.toEqual({ ok: true }) + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 13, + updates: { state: 'closed', stateReason: 'not_planned' } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['issue', 'close', '12', '--repo', 'acme/widgets', '--reason', 'completed'], + { encoding: 'utf-8' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['issue', 'close', '13', '--repo', 'acme/widgets', '--reason', 'not planned'], + { encoding: 'utf-8' } + ) + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('closes slug-addressed duplicate issues with --duplicate-of', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'closed', stateReason: 'duplicate', duplicateOf: 99 } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'close', '12', '--repo', 'acme/widgets', '--duplicate-of', '99'], + { encoding: 'utf-8' } + ) + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('rejects duplicate closes without a target before spawning gh', async () => { + const result = await updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'closed', stateReason: 'duplicate' } + }) + + expect(result).toMatchObject({ + ok: false, + error: { type: 'validation_error' } + }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(runRestMock).not.toHaveBeenCalled() + }) + + it('reopens slug-addressed issues via gh issue reopen', async () => { + await expect( + updateIssueBySlug({ + owner: 'acme', + repo: 'widgets', + number: 12, + updates: { state: 'open' } + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['issue', 'reopen', '12', '--repo', 'acme/widgets'], + { encoding: 'utf-8' } + ) + }) +}) diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index cd72c9e8ce1..e2da3dae068 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -183,17 +183,63 @@ export async function updateIssueBySlug( removeAssignees } = args.updates - // Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}. + if (duplicateOf !== undefined && (state !== 'closed' || stateReason !== 'duplicate')) { + return { + ok: false, + error: { + type: 'validation_error', + message: 'Duplicate target is only valid when closing as duplicate.' + } + } + } + if (state === 'closed' && stateReason === 'duplicate' && duplicateOf === undefined) { + return { + ok: false, + error: { + type: 'validation_error', + message: 'Duplicate target issue number is required.' + } + } + } + if (duplicateOf !== undefined) { + const duplicate = assertPositiveInt(duplicateOf, 'duplicateOf') + if (!duplicate.ok) { + return { ok: false, error: duplicate.error } + } + } + + // Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}. + // State uses gh issue close/reopen so duplicate closes can record a target. // Labels/assignees go through their dedicated endpoints. const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` + if (state !== undefined) { + const stateArgs = + state === 'closed' + ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] + : ['issue', 'reopen', String(args.number), '--repo', `${args.owner}/${args.repo}`] + if (state === 'closed') { + if (stateReason === 'completed') { + stateArgs.push('--reason', 'completed') + } else if (stateReason === 'not_planned') { + stateArgs.push('--reason', 'not planned') + } else if (stateReason === 'duplicate') { + stateArgs.push('--duplicate-of', String(duplicateOf)) + } + } + await acquire() + try { + await ghExecFileAsync(stateArgs, { encoding: 'utf-8' }) + } catch (err) { + const { stderr, stdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, stdout) } + } finally { + release() + } + } + // 1) PATCH body - if ( - title !== undefined || - body !== undefined || - state !== undefined || - stateReason !== undefined - ) { + if (title !== undefined || body !== undefined) { const patchArgs: string[] = ['-X', 'PATCH', base] if (title !== undefined) { patchArgs.push('--raw-field', `title=${title}`) @@ -201,15 +247,6 @@ export async function updateIssueBySlug( if (body !== undefined) { patchArgs.push('--raw-field', `body=${body}`) } - if (state !== undefined) { - patchArgs.push('--raw-field', `state=${state}`) - } - if (stateReason !== undefined) { - patchArgs.push('--raw-field', `state_reason=${stateReason}`) - } - if (duplicateOf !== undefined) { - patchArgs.push('--raw-field', `duplicate_of=${duplicateOf}`) - } const r = await runRest(patchArgs) if (!r.ok) { return { ok: false, error: r.error } diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index e8a81952a59..bbd881d7d1f 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -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] } diff --git a/src/main/index.ts b/src/main/index.ts index dfa09e5f3c9..fc4861f346a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 } diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index f8670c4ebd3..dd7ec9c2697 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -885,6 +885,36 @@ describe('registerFilesystemHandlers', () => { expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { includeIgnored: true }) }) + it('forwards upstream-negative-cache bypass through local and SSH git status IPC', async () => { + registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) + getStatusMock.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + const sshProvider = { + getStatus: vi.fn().mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + } + getSshGitProviderMock.mockReturnValue(sshProvider) + + registerFilesystemHandlers(store as never) + + await handlers.get('git:status')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + bypassEffectiveUpstreamNegativeCache: true + }) + await handlers.get('git:status')!(null, { + worktreePath: '/remote/repo', + connectionId: 'ssh-1', + bypassEffectiveUpstreamNegativeCache: true + }) + + expect(getStatusMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, { + includeIgnored: false, + bypassEffectiveUpstreamNegativeCache: true + }) + expect(sshProvider.getStatus).toHaveBeenCalledWith('/remote/repo', { + includeIgnored: false, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + it('checks ignored paths through local and SSH git providers', async () => { registerWorktreeRootsForRepo(store as never, 'repo-1', [REPO_PATH, WORKTREE_FEATURE_PATH]) checkIgnoredPathsMock.mockResolvedValue(['dist/bundle.js']) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 6f803ca737c..021ee8f4f1d 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -829,9 +829,19 @@ export function registerFilesystemHandlers( 'git:status', async ( _event, - args: { worktreePath: string; connectionId?: string; includeIgnored?: boolean } + args: { + worktreePath: string + connectionId?: string + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + } ): Promise => { - const options = { includeIgnored: args.includeIgnored ?? false } + const options = { + includeIgnored: args.includeIgnored ?? false, + ...(args.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}) + } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index 687c0f8d0a7..d889120497c 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -158,6 +158,7 @@ vi.mock('../telemetry/cohort-classifier', () => ({ })) import { registerGitHubHandlers } from './github' +import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff' type HandlerMap = Record unknown> @@ -237,6 +238,7 @@ describe('registerGitHubHandlers', () => { refreshPRNowMock.mockReset() reportVisiblePRRefreshCandidatesMock.mockReset() setPRRefreshOutcomeObserverMock.mockReset() + clearPRRefreshValidationBackoffForTests() for (const key of Object.keys(handlers)) { delete handlers[key] } @@ -293,6 +295,156 @@ describe('registerGitHubHandlers', () => { expect(getIssueMock).not.toHaveBeenCalled() }) + it('returns typed automatic PR refresh validation skips without enqueueing', async () => { + registerGitHubHandlers(store as never, stats as never) + const candidate = { + cacheKey: 'missing::feature/test', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/test', + repoKind: 'git' as const + } + + const first = await handlers['gh:enqueuePRRefresh'](null, { + candidate, + reason: 'active', + priority: 80 + }) + const second = await handlers['gh:enqueuePRRefresh'](null, { + candidate, + reason: 'active', + priority: 80 + }) + + expect(first).toEqual({ kind: 'skipped', skippedReason: 'validation-denied' }) + expect(second).toEqual({ kind: 'skipped', skippedReason: 'validation-backoff' }) + expect(first).not.toBe(false) + expect(second).not.toBe(false) + expect(enqueuePRRefreshMock).not.toHaveBeenCalled() + }) + + it('uses registered repo routing fields for automatic PR refresh candidates', async () => { + repos = [ + { + id: 'repo-ssh', + path: '/workspace/remote-repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-real', + executionHostId: 'ssh:ssh-real' + } + ] + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:enqueuePRRefresh'](null, { + candidate: { + cacheKey: 'remote::feature/test', + repoPath: '/workspace/remote-repo', + repoId: 'repo-ssh', + branch: 'feature/test', + repoKind: 'git', + connectionId: 'ssh-stale', + executionHostId: 'runtime:stale', + connectionState: 'disconnected', + localGitOptions: { wslDistro: 'Stale' } + }, + reason: 'active', + priority: 80 + }) + + const candidate = enqueuePRRefreshMock.mock.calls[0]?.[0] + expect(candidate).toEqual( + expect.objectContaining({ + repoPath: '/workspace/remote-repo', + repoId: 'repo-ssh', + connectionId: 'ssh-real', + executionHostId: 'ssh:ssh-real', + connectionState: 'connected' + }) + ) + expect(candidate).not.toHaveProperty('localGitOptions') + }) + + it('keeps manual PR refresh validation strict', async () => { + registerGitHubHandlers(store as never, stats as never) + + await expect( + handlers['gh:refreshPRNow'](null, { + candidate: { + cacheKey: 'missing::feature/test', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/test', + repoKind: 'git' + } + }) + ).rejects.toThrow('Access denied: unknown repository path') + + expect(refreshPRNowMock).not.toHaveBeenCalled() + }) + + it('filters invalid visible PR refresh candidates while keeping valid candidates', async () => { + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:reportVisiblePRRefreshCandidates']( + { sender: { id: 7, once: vi.fn() } }, + { + generation: 1, + candidates: [ + { + cacheKey: 'valid::feature/test', + repoPath: '/workspace/repo', + repoId: 'repo-1', + branch: 'feature/test', + repoKind: 'git' + }, + { + cacheKey: 'missing::feature/old', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/old', + repoKind: 'git' + } + ] + } + ) + + expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith( + [ + expect.objectContaining({ + cacheKey: 'valid::feature/test', + repoPath: '/workspace/repo', + repoId: 'repo-1' + }) + ], + 1, + 7 + ) + }) + + it('clears a sender visible PR refresh set when all current candidates are invalid', async () => { + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:reportVisiblePRRefreshCandidates']( + { sender: { id: 8, once: vi.fn() } }, + { + generation: 2, + candidates: [ + { + cacheKey: 'missing::feature/old', + repoPath: '/workspace/missing', + repoId: 'missing-repo', + branch: 'feature/old', + repoKind: 'git' + } + ] + } + ) + + expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith([], 2, 8) + }) + it('rejects GitHub source context from a different host', async () => { registerGitHubHandlers(store as never, stats as never) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index f4c4b1a6401..87b873e73af 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -11,6 +11,7 @@ import type { GitHubOwnerRepo, GitHubPullRequestStateUpdate, GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, GitHubPRRefreshReason, PRRefreshOutcome } from '../../shared/types' @@ -62,6 +63,10 @@ import { import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' import { getRateLimit } from '../github/rate-limit' import { diagnoseGhAuth } from '../github/auth-diagnose' +import { + notePRRefreshValidationDenial, + type PRRefreshValidationDenialReason +} from '../github/pr-refresh-validation-backoff' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import type { GitHubPRFile } from '../../shared/types' import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' @@ -140,27 +145,53 @@ type RepoScopedArgs = { sourceContext?: TaskSourceContext | null } -function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { +type RegisteredRepoValidationResult = + | { kind: 'ok'; repo: Repo } + | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } + +function validateRegisteredRepo( + args: string | RepoScopedArgs, + store: Store, + repos = store.getRepos() +): RegisteredRepoValidationResult { const repoPath = typeof args === 'string' ? args : args.repoPath const repoId = typeof args === 'string' ? undefined : args.repoId const resolvedRepoPath = resolve(repoPath) - const repo = store - .getRepos() - .find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) + const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) if (!repo) { - throw new Error('Access denied: unknown repository path') + return { + kind: 'denied', + reason: 'unknown-repo', + message: 'Access denied: unknown repository path' + } } if (repoId && resolve(repo.path) !== resolvedRepoPath) { - throw new Error('Access denied: repository path does not match repo id') + return { + kind: 'denied', + reason: 'repo-path-mismatch', + message: 'Access denied: repository path does not match repo id' + } } if ( typeof args !== 'string' && args.sourceContext?.provider === 'github' && args.sourceContext.hostId !== getRepoExecutionHostId(repo) ) { - throw new Error('Access denied: GitHub source host does not match repository host') + return { + kind: 'denied', + reason: 'host-mismatch', + message: 'Access denied: GitHub source host does not match repository host' + } } - return repo + return { kind: 'ok', repo } +} + +function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { + const result = validateRegisteredRepo(args, store) + if (result.kind === 'denied') { + throw new Error(result.message) + } + return result.repo } function repoConnectionId(repo: Repo): string | null { @@ -172,6 +203,50 @@ function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: strin return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] } +function applyRepoToPRRefreshCandidate( + store: Store, + repo: Repo, + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshCandidate { + const localGitOptions = localGitOptionArgs(store, repo)[0] + const appliedCandidate = { ...candidate } + delete appliedCandidate.localGitOptions + delete appliedCandidate.connectionId + delete appliedCandidate.executionHostId + delete appliedCandidate.connectionState + return { + ...appliedCandidate, + repoPath: repo.path, + repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), + connectionId: repoConnectionId(repo), + executionHostId: repo.executionHostId ?? null, + connectionState: repo.connectionId ? 'connected' : 'unknown' + } +} + +function validateAutomaticPRRefreshCandidate( + candidate: GitHubPRRefreshCandidate, + store: Store, + repos = store.getRepos() +): + | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } + | { + kind: 'skipped' + result: Extract + } { + const result = validateRegisteredRepo(candidate, store, repos) + if (result.kind === 'denied') { + const skippedReason = notePRRefreshValidationDenial({ + repoId: candidate.repoId, + repoPath: candidate.repoPath, + reason: result.reason + }) + return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } + } + return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) } +} + export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { @@ -246,16 +321,8 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle( 'gh:refreshPRNow', async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { - const repo = assertRegisteredRepo(args.candidate.repoPath, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - const outcome = await refreshPRNow({ - ...args.candidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repo.connectionId ?? args.candidate.connectionId, - connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState - }) + const repo = assertRegisteredRepo(args.candidate, store) + const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate)) recordPRIfNeeded(repo, outcome) return outcome } @@ -270,22 +337,13 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi reason: GitHubPRRefreshReason priority?: number } - ) => { - const repo = assertRegisteredRepo(args.candidate.repoPath, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - enqueuePRRefresh( - { - ...args.candidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repo.connectionId ?? args.candidate.connectionId, - connectionState: repo.connectionId ? 'connected' : args.candidate.connectionState - }, - args.reason, - args.priority ?? 0 - ) - return true + ): GitHubPRRefreshEnqueueResult => { + const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) + if (validation.kind === 'skipped') { + return validation.result + } + enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0) + return { kind: 'queued' } } ) @@ -300,18 +358,14 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi clearVisiblePRRefreshWindow(senderId) }) } - const candidates = args.candidates.map((candidate) => { - const repo = assertRegisteredRepo(candidate.repoPath, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return { - ...candidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repo.connectionId ?? candidate.connectionId, - connectionState: repo.connectionId ? 'connected' : candidate.connectionState + const candidates: GitHubPRRefreshCandidate[] = [] + const repos = store.getRepos() + for (const candidate of args.candidates) { + const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) + if (validation.kind === 'ok') { + candidates.push(validation.candidate) } - }) + } reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) return true } diff --git a/src/main/kimi/kimi-hook-config-toml.ts b/src/main/kimi/kimi-hook-config-toml.ts index 5e80080704c..82118c67596 100644 --- a/src/main/kimi/kimi-hook-config-toml.ts +++ b/src/main/kimi/kimi-hook-config-toml.ts @@ -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') } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 2ebcf99053c..a6fd5911fa2 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -69,6 +69,22 @@ describe('SshGitProvider', () => { }) }) + it('getStatus forwards upstream-negative-cache bypass only when requested', async () => { + const statusResult = { entries: [], conflictOperation: 'unknown' } + mux.request.mockResolvedValue(statusResult) + + await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: true }) + await provider.getStatus('/home/user/repo', { bypassEffectiveUpstreamNegativeCache: false }) + + expect(mux.request).toHaveBeenNthCalledWith(1, 'git.status', { + worktreePath: '/home/user/repo', + bypassEffectiveUpstreamNegativeCache: true + }) + expect(mux.request).toHaveBeenNthCalledWith(2, 'git.status', { + worktreePath: '/home/user/repo' + }) + }) + it('checkIgnoredPaths sends git.checkIgnored request', async () => { mux.request.mockResolvedValue(['dist/bundle.js']) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index b9495dd7e7c..9c93c46e6d5 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -3,7 +3,7 @@ indirection — every method is a 1:1 forwarder to a relay RPC plus a small amount of param plumbing. */ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { IGitProvider } from './types' +import type { GitProviderStatusOptions, IGitProvider } from './types' import type { GitStatusResult, GitDiffResult, @@ -82,12 +82,16 @@ export class SshGitProvider implements IGitProvider { async getStatus( worktreePath: string, - options?: { includeIgnored?: boolean } + options?: GitProviderStatusOptions ): Promise { const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {} return (await this.mux.request('git.status', { worktreePath, - ...includeIgnoredArgs + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs })) as GitStatusResult } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 1a11df9e1af..c8a090e59fa 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -165,8 +165,13 @@ export type IFilesystemProvider = { // ─── Git Provider ─────────────────────────────────────────────────── +export type GitProviderStatusOptions = { + includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean +} + export type IGitProvider = { - getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise + getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise getHistory(worktreePath: string, options?: GitHistoryOptions): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index c2cd775ac0e..e39ef000eea 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -23,6 +23,7 @@ import { type ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' +import type { GitProviderStatusOptions } from '../providers/types' import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' import { abortMerge, @@ -164,7 +165,7 @@ export class RuntimeGitCommands { async getRuntimeGitStatus( worktreeSelector: string, - options?: { includeIgnored?: boolean } + options?: GitProviderStatusOptions ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 25dfa727a77..46fb3088e08 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -14716,6 +14716,8 @@ describe('OrcaRuntimeService', () => { parentWorktreeId: null, childWorktreeIds: [], displayName: 'foo', + workspaceStatus: 'in-progress', + sortOrder: 0, linkedIssue: 123, linkedPR: null, linkedLinearIssue: null, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bd81e59fcc7..fef93b96e19 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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, diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index db17fc8d087..587dc4db7a8 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -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 @@ -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 ?? ''}` - ) - 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 { diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts new file mode 100644 index 00000000000..9036c145534 --- /dev/null +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -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 { + if (!msg.payload) { + return {} + } + + try { + const parsed: unknown = JSON.parse(msg.payload) + return parsed && typeof parsed === 'object' ? (parsed as Record) : {} + } 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 ?? ''}` + ) + 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 } +} diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index ca80c969709..c94c3349c6c 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -8,7 +8,8 @@ export const WorktreeSelector = z.object({ }) export const GitStatusParams = WorktreeSelector.extend({ - includeIgnored: z.boolean().optional() + includeIgnored: z.boolean().optional(), + bypassEffectiveUpstreamNegativeCache: z.boolean().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index b5937d8fc61..c6307c0aa63 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -55,6 +55,32 @@ describe('git RPC methods', () => { }) }) + it('forwards upstream-negative-cache bypass for status requests', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitStatus: vi.fn().mockResolvedValue({ + entries: [], + conflictOperation: 'unknown' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.status', { + worktree: 'id:wt-1', + bypassEffectiveUpstreamNegativeCache: true + }) + ) + + expect(runtime.getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1', { + bypassEffectiveUpstreamNegativeCache: true + }) + expect(response).toMatchObject({ + ok: true, + result: { entries: [] } + }) + }) + it('returns ignored paths for selected explorer rows', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 06cc3205452..964f7d09ce3 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -87,10 +87,23 @@ export const GIT_METHODS: RpcMethod[] = [ defineMethod({ name: 'git.status', params: GitStatusParams, - handler: async (params, { runtime }) => - params.includeIgnored === undefined + handler: async (params, { runtime }) => { + const options = + params.includeIgnored === undefined && + params.bypassEffectiveUpstreamNegativeCache === undefined + ? undefined + : { + ...(params.includeIgnored === undefined + ? {} + : { includeIgnored: params.includeIgnored }), + ...(params.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}) + } + return options === undefined ? runtime.getRuntimeGitStatus(params.worktree) - : runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored }) + : runtime.getRuntimeGitStatus(params.worktree, options) + } }), defineMethod({ name: 'git.checkIgnored', diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 190a3ec2b90..368b77b2072 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -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 = {} + 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( diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index bd5f950815b..64a0b9969a7 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -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)) } diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index d052ad24d65..964afa701e6 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -2763,6 +2763,7 @@ describe('OrcaRuntimeRpcServer', () => { path: '/tmp/worktree-a', branch: 'feature/foo', linkedIssue: 123, + sortOrder: 0, unread: true, liveTerminalCount: 1, hasAttachedPty: true, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e57c86aa041..55c32a9a5d9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -115,6 +115,7 @@ import type { FloatingTerminalCwdRequest, GitHubIssueUpdate, GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, GitHubPRRefreshEvent, GitHubPRRefreshReason, GetRateLimitResult, @@ -1156,7 +1157,7 @@ export type PreloadApi = { candidate: GitHubPRRefreshCandidate reason: GitHubPRRefreshReason priority?: number - }) => Promise + }) => Promise reportVisiblePRRefreshCandidates: (args: { candidates: GitHubPRRefreshCandidate[] generation: number @@ -2107,6 +2108,7 @@ export type PreloadApi = { worktreePath: string connectionId?: string includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean }) => Promise checkIgnored: (args: { worktreePath: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 7852bd91f31..7936edb853b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2536,6 +2536,7 @@ const api = { worktreePath: string connectionId?: string includeIgnored?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean }): Promise => ipcRenderer.invoke('git:status', args), checkIgnored: (args: { worktreePath: string diff --git a/src/relay/git-handler-status-ops.test.ts b/src/relay/git-handler-status-ops.test.ts index d55e86d7584..5c409f4c943 100644 --- a/src/relay/git-handler-status-ops.test.ts +++ b/src/relay/git-handler-status-ops.test.ts @@ -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(async (args) => { + if (args.includes('status')) { + return { stdout: buildBranchStatusOutput('abc123', 'feature'), stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n', stderr: '' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + + await getStatusOp(git, { worktreePath: tmpDir }) + vi.setSystemTime(31_000) + await getStatusOp(git, { worktreePath: tmpDir }) + + expect( + git.mock.calls.filter(([args]) => args[0] === 'rev-parse' && args.includes('HEAD@{u}')) + ).toHaveLength(1) + }) + it('coalesces concurrent no-effective-upstream probes', async () => { const git = vi.fn(async (args) => { if (args.includes('status')) { diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 8619e6fd1e9..03fe7c35b69 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -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 diff --git a/src/relay/git-status-upstream-negative-cache.test.ts b/src/relay/git-status-upstream-negative-cache.test.ts new file mode 100644 index 00000000000..e232f791265 --- /dev/null +++ b/src/relay/git-status-upstream-negative-cache.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearNoEffectiveUpstreamStatusCache, + clearNoEffectiveUpstreamStatusCacheEntry, + getNoEffectiveUpstreamStatusCacheCountForTests, + getNoEffectiveUpstreamStatusGenerationCountForTests, + readOrProbeNoEffectiveUpstreamStatus +} from './git-status-upstream-negative-cache' + +describe('relay upstream negative cache', () => { + beforeEach(() => { + clearNoEffectiveUpstreamStatusCache() + }) + + afterEach(() => { + clearNoEffectiveUpstreamStatusCache() + }) + + it('bypasses cached no-effective-upstream status when requested', async () => { + let originBranchExists = false + const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + throw new Error('missing remote branch') + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const first = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + originBranchExists = true + const automatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { + bypassCache: true + }) + + expect(first).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(automatic).toEqual(first) + expect(strict).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + }) + + it('keeps an older automatic negative probe from overwriting a strict positive result', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const runGit = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { + bypassCache: true + }) + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + const staleAutomatic = await automatic + const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + + expect(strict).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(staleAutomatic).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) + expect(nextAutomatic).toEqual(strict) + }) + + it('does not trim generation for an unresolved automatic probe', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + const strict = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit, { + bypassCache: true + }) + for (let index = 0; index < 512; index += 1) { + const branchName = `other-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }, + { bypassCache: true } + ) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + + expect(strict).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(nextAutomatic).toEqual(strict) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('does not trim generation for a cleared automatic probe before it settles', async () => { + let originBranchExists = false + let deferredOriginReject: ((error: Error) => void) | null = null + const runGit = vi.fn(async (args: string[]): Promise<{ stdout: string }> => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error('fatal: no upstream configured for branch feature') + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/feature')) { + if (originBranchExists) { + return { stdout: 'abc123\n' } + } + return await new Promise<{ stdout: string }>((_, reject) => { + deferredOriginReject = reject + }) + } + if (args[0] === 'rev-list' && args.includes('HEAD...origin/feature')) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }) + const identity = { worktreePath: '/repo', branchName: 'feature' } + + const automatic = readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + await vi.waitFor(() => expect(deferredOriginReject).toBeTruthy()) + + originBranchExists = true + clearNoEffectiveUpstreamStatusCacheEntry(identity) + for (let index = 0; index < 512; index += 1) { + const branchName = `other-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }, + { bypassCache: true } + ) + } + if (!deferredOriginReject) { + throw new Error('expected deferred origin reject') + } + ;(deferredOriginReject as (error: Error) => void)(new Error('missing remote branch')) + await automatic + const nextAutomatic = await readOrProbeNoEffectiveUpstreamStatus(identity, runGit) + + expect(nextAutomatic).toEqual({ + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + }) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds no-effective-upstream entries', async () => { + for (let index = 0; index < 513; index += 1) { + const branchName = `feature-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + throw new Error('missing remote branch') + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + } + ) + } + + expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(512) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBeLessThanOrEqual(512) + }) + + it('bounds write-generation entries from positive strict probes', async () => { + for (let index = 0; index < 513; index += 1) { + const branchName = `feature-${index}` + await readOrProbeNoEffectiveUpstreamStatus( + { worktreePath: '/repo', branchName }, + async (args) => { + if (args[0] === 'symbolic-ref') { + return { stdout: `${branchName}\n` } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error(`fatal: no upstream configured for branch ${branchName}`) + } + if (args[0] === 'rev-parse' && args.includes(`refs/remotes/origin/${branchName}`)) { + return { stdout: 'abc123\n' } + } + if (args[0] === 'rev-list' && args.includes(`HEAD...origin/${branchName}`)) { + return { stdout: '0\t1\n' } + } + throw new Error(`No upstream fixture for git ${args.join(' ')}`) + }, + { bypassCache: true } + ) + } + + expect(getNoEffectiveUpstreamStatusCacheCountForTests()).toBe(0) + expect(getNoEffectiveUpstreamStatusGenerationCountForTests()).toBe(512) + }) +}) diff --git a/src/relay/git-status-upstream-negative-cache.ts b/src/relay/git-status-upstream-negative-cache.ts index ec1764891c8..a9dbec3175c 100644 --- a/src/relay/git-status-upstream-negative-cache.ts +++ b/src/relay/git-status-upstream-negative-cache.ts @@ -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() const noEffectiveUpstreamInFlight = new Map>() +const retiredNoEffectiveUpstreamInFlight = new Map>() +const noEffectiveUpstreamWriteGeneration = new Map() function noEffectiveUpstreamCacheKey(identity: NoEffectiveUpstreamCacheIdentity): string { return [identity.worktreePath, identity.branchName, identity.upstreamName ?? ''].join('\0') @@ -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/; 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 { 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 } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3bcbf8887c2..1071ff9ae8d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index c35ddee07af..a417419855e 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -17,10 +17,12 @@ import { ArrowDown, ArrowRight, ArrowUp, + Ban, Braces, Check, ChevronDown, ChevronLeft, + ChevronRight, CircleDashed, CircleDot, Copy, @@ -38,10 +40,10 @@ import { Pencil, Plus, RefreshCw, + Search, Send, Settings, UndoDot, - Users, Wrench, X } from 'lucide-react' @@ -59,7 +61,7 @@ import { } from '@/components/ui/accordion' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { DropdownMenu, DropdownMenuContent, @@ -159,6 +161,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' +import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, @@ -193,6 +196,13 @@ import { import { PER_REPO_FETCH_LIMIT } from '../../../shared/work-items' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget, + type TaskPageGitHubCloseAction +} from '@/components/task-page-github-status-actions' // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. @@ -354,7 +364,9 @@ function getStateTone(item: GitHubWorkItem): string { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } if (item.state === 'closed') { - return 'border-rose-500/30 bg-rose-500/10 text-rose-600 dark:text-rose-300' + // Why: closed issues can mean completed/resolved; keep them neutral instead + // of using destructive red, which is reserved for PR closed-without-merge. + return 'border-ring/50 bg-primary/10 text-foreground' } return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } @@ -386,25 +398,15 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - if (avatarUrl) { - return ( - - ) - } return ( - - {login.slice(0, 1).toUpperCase()} - + className="size-6 shrink-0 rounded-full border border-border/50 bg-muted object-cover" + /> ) } @@ -447,6 +449,239 @@ function buildRequestedReviewUsers( return Array.from(byLogin.values()) } +function PRAssigneesPanel({ + item, + repoPath, + projectOrigin, + sourceContext, + onMutated +}: { + item: GitHubWorkItem + repoPath: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + sourceContext?: TaskSourceContext | null + onMutated: () => void +}): React.JSX.Element { + const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) + const [localAssignees, setLocalAssignees] = useState( + () => item.assignees ?? [] + ) + const [assigneesSource, setAssigneesSource] = useState(() => ({ + itemId: item.id, + repoId: item.repoId, + assignees: item.assignees + })) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const repoOwnerSettings = useAppStore( + useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) + ) + const sourceSettings = useMemo( + () => + sourceContext?.provider === 'github' + ? ({ + ...repoOwnerSettings, + ...getTaskSourceRuntimeSettings(sourceContext) + } as typeof repoOwnerSettings) + : repoOwnerSettings, + [repoOwnerSettings, sourceContext] + ) + const { isPending, run } = useImmediateMutation() + + // Why: PR assignees can change through background refetches; sync them + // before paint so the right rail never shows a stale reviewer/assignee split. + if ( + assigneesSource.itemId !== item.id || + assigneesSource.repoId !== item.repoId || + assigneesSource.assignees !== item.assignees + ) { + setAssigneesSource({ itemId: item.id, repoId: item.repoId, assignees: item.assignees }) + setLocalAssignees(item.assignees ?? []) + } + + const patchProjectRowIfNeeded = useCallback( + (assignees: string[]) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { assignees }) + }, + [patchProjectRowContent, projectOrigin] + ) + const assigneeLogins = useMemo(() => localAssignees.map((user) => user.login), [localAssignees]) + const assigneeSlug = useMemo(() => parseOwnerRepoFromItemUrl(item.url), [item.url]) + const slugOwner = projectOrigin?.owner ?? assigneeSlug?.owner ?? null + const slugRepo = projectOrigin?.repo ?? assigneeSlug?.repo ?? null + const repoAssigneesBySlug = useRepoAssigneesBySlug( + slugOwner, + slugRepo, + assigneeLogins, + sourceSettings + ) + const repoAssigneesByPath = useRepoAssignees(repoPath, item.repoId, sourceSettings) + const repoAssignees = slugOwner && slugRepo ? repoAssigneesBySlug : repoAssigneesByPath + const canEditAssignees = Boolean(projectOrigin || repoPath) + const assigneesByLogin = useMemo( + () => new Map(repoAssignees.data.map((user) => [user.login.toLowerCase(), user])), + [repoAssignees.data] + ) + + const handleAssigneeToggle = useCallback( + (login: string) => { + const lowerLogin = login.toLowerCase() + const isAssigned = localAssignees.some((user) => user.login.toLowerCase() === lowerLogin) + const prevAssignees = localAssignees + const candidate = assigneesByLogin.get(lowerLogin) ?? { login, name: null, avatarUrl: '' } + const nextAssignees = isAssigned + ? prevAssignees.filter((user) => user.login.toLowerCase() !== lowerLogin) + : [...prevAssignees, candidate] + const nextLogins = nextAssignees.map((user) => user.login) + const prevLogins = prevAssignees.map((user) => user.login) + + run('assignees', { + mutate: () => + runIssueUpdate({ + repoId: item.repoId, + repoPath, + sourceContext, + projectOrigin, + number: item.number, + updates: isAssigned ? { removeAssignees: [login] } : { addAssignees: [login] } + }), + onOptimistic: () => { + setLocalAssignees(nextAssignees) + patchWorkItem(item.id, { assignees: nextAssignees }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(nextLogins) + }, + onRevert: () => { + setLocalAssignees(prevAssignees) + patchWorkItem(item.id, { assignees: prevAssignees }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded(prevLogins) + }, + onSuccess: () => { + useAppStore.getState().recordFeatureInteraction('github-tasks') + onMutated() + }, + onError: (err) => toast.error(err) + }) + }, + [ + assigneesByLogin, + item.id, + item.number, + item.repoId, + localAssignees, + onMutated, + patchProjectRowIfNeeded, + patchWorkItem, + projectOrigin, + repoPath, + run, + sourceContext + ] + ) + + const checkIcon = ( + + + + ) + + return ( +
+
+ {translate('auto.components.GitHubItemDialog.83ac703dda', 'Assignees')} + + + + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => { + const selected = localAssignees.some( + (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() + ) + return ( + + ) + })} +
+ )} +
+
+
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.GitHubItemDialog.c67de9e2fe', 'No one assigned')} +
+ ) : ( +
    + {localAssignees.map((assignee) => ( +
  • + + + {assignee.login} + +
  • + ))} +
+ )} +
+ ) +} + function PRReviewersPanel({ item, loading, @@ -462,8 +697,6 @@ function PRReviewersPanel({ }): React.JSX.Element { const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') - const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') - const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 @@ -663,26 +896,6 @@ function PRReviewersPanel({ const canRequestReview = !!repoPath || getActiveRuntimeTarget(sourceSettings).kind === 'environment' - const measureReviewerPickerPlacement = useCallback(() => { - const rect = reviewerInputRef.current?.getBoundingClientRect() - if (!rect) { - setReviewerPickerSide('bottom') - setReviewerPickerMaxHeight(null) - return - } - - const gap = 8 - const minUsefulHeight = 180 - const availableBelow = window.innerHeight - rect.bottom - gap - const availableAbove = rect.top - gap - const nextSide = - availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom' - const available = nextSide === 'top' ? availableAbove : availableBelow - - setReviewerPickerSide(nextSide) - setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available))) - }, []) - const handleRequestReview = async (requestedLogins?: string[]): Promise => { if (submitting) { return @@ -856,9 +1069,6 @@ function PRReviewersPanel({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - if (nextOpen) { - measureReviewerPickerPlacement() - } setOpen(nextOpen) if (nextOpen) { scheduleReviewerInputFocus() @@ -936,165 +1146,80 @@ function PRReviewersPanel({ } return ( - + {loading && !hasReviewerMetadata ? ( +
+ + {translate('auto.components.GitHubItemDialog.6a45771d47', 'Loading reviewers')} +
+ ) : reviewers.length > 0 ? ( +
+ {reviewers.map((reviewer) => { + const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + return ( +
+ +
+
+ {reviewer.login} +
+ {reviewer.name ? ( +
+ {reviewer.name} +
+ ) : null} +
+ + {reviewer.stateLabel} + + {canRemoveReviewer ? ( + + + + + + {translate('auto.components.GitHubItemDialog.5c1c973855', 'Remove reviewer')} + + + ) : null} +
+ ) + })} +
+ ) : ( +
+ {translate('auto.components.GitHubItemDialog.36f9ac4a47', 'No reviewers requested.')} +
+ )} + ) } @@ -2794,7 +2979,7 @@ function ConversationTab({ const rightPanel = item.type === 'pr' ? ( -
+
+ (null) const [localAssignees, setLocalAssignees] = useState(assignees) const editedAssigneesItemKeyRef = useRef(null) const assigneesItemKey = `${item.repoId}\0${item.id}` const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) const repoOwnerSettings = useAppStore( useShallow((s) => getSettingsForRepoRuntimeOwner(s, item.repoId ?? null)) ) @@ -4723,6 +4940,33 @@ function GHEditSection({ const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath const hasAttachedWorkspace = attachedWorkspaceLabel !== null && attachedWorkspaceLabel !== undefined + const filteredDuplicateCandidates = useMemo( + () => + getTaskPageGitHubDuplicateCandidates(duplicateIssueCandidates, item.number, duplicateSearch), + [duplicateIssueCandidates, duplicateSearch, item.number] + ) + const directDuplicateTarget = useMemo(() => { + const trimmed = duplicateSearch.trim() + const validation = validateTaskPageGitHubDuplicateTarget(trimmed, item.number) + if (!trimmed || !validation.ok) { + return null + } + if ( + filteredDuplicateCandidates.some((candidate) => candidate.number === validation.duplicateOf) + ) { + return null + } + return validation.duplicateOf + }, [duplicateSearch, filteredDuplicateCandidates, item.number]) + const duplicatePickerTitle = useMemo(() => { + if (projectOrigin) { + return `${projectOrigin.owner}/${projectOrigin.repo}` + } + const parsed = parseOwnerRepoFromItemUrl(item.url) + return parsed + ? `${parsed.owner}/${parsed.repo}` + : translate('auto.components.TaskPage.repository', 'Repository') + }, [item.url, projectOrigin]) const handleOpenOrUseWorkspace = useCallback((): void => { if (onOpenOrUse) { onOpenOrUse(item) @@ -4742,7 +4986,7 @@ function GHEditSection({ }, [assigneesItemKey, assignees]) const handleStateChange = useCallback( - (newState: 'open' | 'closed') => { + (newState: 'open' | 'closed', closeAction?: TaskPageGitHubCloseAction) => { if (newState === localState) { return } @@ -4755,7 +4999,10 @@ function GHEditSection({ sourceContext, projectOrigin, number: item.number, - updates: { state: newState } + updates: + newState === 'closed' && closeAction + ? buildTaskPageGitHubCloseUpdate(closeAction) + : { state: newState } }), onOptimistic: () => { onStateChange(newState) @@ -4792,6 +5039,42 @@ function GHEditSection({ ] ) + const closeAsDuplicate = useCallback( + (targetIssueNumber: number | string) => { + const validation = validateTaskPageGitHubDuplicateTarget( + String(targetIssueNumber), + item.number + ) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + setDuplicateError(null) + handleStateChange('closed', { stateReason: 'duplicate', duplicateOf: validation.duplicateOf }) + setStatusPopoverOpen(false) + setDuplicatePickerOpen(false) + }, + [handleStateChange, item.number] + ) + + const handleDuplicateSearchSubmit = useCallback(() => { + const validation = validateTaskPageGitHubDuplicateTarget(duplicateSearch, item.number) + if (!validation.ok) { + setDuplicateError(getTaskPageGitHubDuplicateTargetErrorMessage(validation, translate)) + return + } + closeAsDuplicate(validation.duplicateOf) + }, [closeAsDuplicate, duplicateSearch, item.number]) + + const handleStatusPopoverOpenChange = useCallback((nextOpen: boolean) => { + setStatusPopoverOpen(nextOpen) + if (!nextOpen) { + setDuplicatePickerOpen(false) + setDuplicateSearch('') + setDuplicateError(null) + } + }, []) + const handleLabelToggle = useCallback( (label: string) => { const isAdding = !localLabels.includes(label) @@ -4947,6 +5230,196 @@ function GHEditSection({ ] ) + const renderIssueStatusPopover = (variant: 'sidebar' | 'pill'): React.JSX.Element => { + const isSidebar = variant === 'sidebar' + return ( + + + + + + {duplicatePickerOpen ? ( +
+
+ + + {duplicatePickerTitle} + +
+
+ + { + setDuplicateSearch(event.target.value) + setDuplicateError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

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

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

+ ) : null} +
+
+ ) : ( + <> + + + + + + )} +
+
+ ) + } + if (item.type === 'pr') { return null } @@ -4971,51 +5444,7 @@ function GHEditSection({
{translate('auto.components.GitHubItemDialog.00ccdf9b5a', 'Status')}
- - - - - - - - - + {renderIssueStatusPopover('sidebar')} {/* Assignees */} @@ -5264,44 +5693,7 @@ function GHEditSection({ return (
{/* State */} - - - - - - - - - + {renderIssueStatusPopover('pill')} {/* Labels */} @@ -5571,29 +5963,37 @@ function GHCommentComposer({ const canSubmitComment = hasBoundedCommentBodyText(body) return ( -
+
void handleSubmit()} /> - + + + + + + {translate('auto.components.GitHubItemDialog.0a73f59e85', 'Send comment')} + +
) } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 0079a9aa868..6c1f8c1d9bf 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -95,6 +95,10 @@ type NewWorkspaceComposerCardProps = { canReuseSelectedBranch: boolean reuseSelectedBranch: boolean onReuseSelectedBranchChange: (next: boolean) => void + /** Shows the footer "Create more" switch — worktree targets only. */ + showCreateMultiple?: boolean + createMultiple?: boolean + onCreateMultipleChange?: (next: boolean) => void smartNameGitHubSourceContext?: TaskSourceContext | null /** Advisory shown under the name field when a fork PR can't accept maintainer pushes. */ forkPushWarning: string | null @@ -331,6 +335,9 @@ export default function NewWorkspaceComposerCard({ canReuseSelectedBranch, reuseSelectedBranch, onReuseSelectedBranchChange, + showCreateMultiple = false, + createMultiple = false, + onCreateMultipleChange, smartNameGitHubSourceContext, forkPushWarning, detectedAgentIds, @@ -810,7 +817,10 @@ export default function NewWorkspaceComposerCard({ />
-
+ {/* Why: Advanced is a disclosure header, so keep it visually grouped + with the content or footer below it while preserving normal spacing + from the Agent field above. */} +
+ ) : null} + + + {repoAssignees.error ? ( +
+ {repoAssignees.error} +
+ ) : ( +
+ {repoAssignees.data.map((user) => { + const selected = localAssignees.some( + (assignee) => assignee.login.toLowerCase() === user.login.toLowerCase() + ) + return ( + + ) + })} +
+ )} +
+ +
+ {localAssignees.length === 0 ? ( +
+ {translate('auto.components.PullRequestPage.1ff5d979df', 'No one assigned')} +
+ ) : ( +
    + {localAssignees.map((assignee) => ( +
  • + + + {assignee.login} + +
  • + ))} +
+ )} + + ) +} + function PRReviewersPanel({ item, loading, @@ -522,8 +728,6 @@ function PRReviewersPanel({ }): React.JSX.Element { const [open, setOpen] = useState(false) const [reviewerInput, setReviewerInput] = useState('') - const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') - const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [activeReviewerCursor, setActiveReviewerCursor] = useState({ resetKey: '', index: 0 @@ -713,26 +917,6 @@ function PRReviewersPanel({ const canRequestReview = !!repoPath || getActiveRuntimeTarget(repoOwnerSettings).kind === 'environment' - const measureReviewerPickerPlacement = useCallback(() => { - const rect = reviewerInputRef.current?.getBoundingClientRect() - if (!rect) { - setReviewerPickerSide('bottom') - setReviewerPickerMaxHeight(null) - return - } - - const gap = 8 - const minUsefulHeight = 180 - const availableBelow = window.innerHeight - rect.bottom - gap - const availableAbove = rect.top - gap - const nextSide = - availableBelow < minUsefulHeight && availableAbove > availableBelow ? 'top' : 'bottom' - const available = nextSide === 'top' ? availableAbove : availableBelow - - setReviewerPickerSide(nextSide) - setReviewerPickerMaxHeight(Math.max(120, Math.min(330, available))) - }, []) - const handleRequestReview = async (requestedLogins?: string[]): Promise => { if (submitting) { return @@ -898,9 +1082,6 @@ function PRReviewersPanel({ } const handleReviewerPickerOpenChange = (nextOpen: boolean): void => { - if (nextOpen) { - measureReviewerPickerPlacement() - } setOpen(nextOpen) if (nextOpen) { scheduleReviewerInputFocus() @@ -978,162 +1159,80 @@ function PRReviewersPanel({ } return ( - + {loading && !hasReviewerMetadata ? ( +
+ + {translate('auto.components.PullRequestPage.acbd110867', 'Loading reviewers')} +
+ ) : reviewers.length > 0 ? ( +
+ {reviewers.map((reviewer) => { + const canRemoveReviewer = selectedReviewerLogins.has(reviewer.login.toLowerCase()) + return ( +
+ +
+
+ {reviewer.login} +
+ {reviewer.name ? ( +
+ {reviewer.name} +
+ ) : null} +
+ + {reviewer.stateLabel} + + {canRemoveReviewer ? ( + + + + + + {translate('auto.components.PullRequestPage.7f964a365a', 'Remove reviewer')} + + + ) : null} +
+ ) + })} +
+ ) : ( +
+ {translate('auto.components.PullRequestPage.d10b6d5209', 'No reviewers requested.')} +
+ )} + ) } @@ -2968,7 +3127,7 @@ function ConversationTab({ const rightPanel = item.type === 'pr' ? ( -
+
+ +
- + + + + + + {translate('auto.components.PullRequestPage.161d91ef02', 'Send comment')} + +
) } diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index e9495d4c30f..fc56f6692c1 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -9,6 +9,7 @@ import { AlertCircle, ArrowDownUp, ArrowRight, + Ban, Check, CheckCircle2, ChevronDown, @@ -16,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(null) + const duplicateIssueCandidates = useAppStore( + useShallow((s) => { + if (!duplicatePickerOpen) { + return [] + } + const deduped = new Map() + for (const entry of Object.values(s.workItemsCache)) { + for (const candidate of entry.data ?? []) { + if ( + candidate.type === 'issue' && + candidate.repoId === item.repoId && + candidate.number !== item.number && + !deduped.has(candidate.number) + ) { + deduped.set(candidate.number, candidate) + } + } + } + return Array.from(deduped.values()).sort((a, b) => b.number - a.number) + }) + ) const repoOwnerSettings = useAppStore( useShallow((s) => getSettingsForRepoRuntimeOwner(s, repo?.id ?? null)) ) @@ -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 } return ( - + - e.stopPropagation()}> - - + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {duplicatePickerOpen ? ( +
+
+ + + {duplicatePickerTitle} + +
+
+ + { + setDuplicateSearch(event.target.value) + setDuplicateError(null) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleDuplicateSearchSubmit() + } + }} + placeholder={translate('auto.components.TaskPage.searchIssues', 'Search issues')} + className="h-9 pl-8 text-[12px]" + aria-invalid={duplicateError ? true : undefined} + /> +
+ {duplicateError ? ( +

{duplicateError}

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

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

+ ) : null} +
+
+ ) : ( + <> + + + + + + )}
) @@ -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 ( ) } - if (reviewer?.login) { - return ( - - {reviewer.login.slice(0, 1).toUpperCase()} - - ) - } - return + return } 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( () => item.reviewRequests ?? [] ) + const [reviewerPickerSide, setReviewerPickerSide] = useState<'top' | 'bottom'>('bottom') + const [reviewerPickerMaxHeight, setReviewerPickerMaxHeight] = useState(null) const [reviewRequestsSource, setReviewRequestsSource] = useState(() => ({ itemId: item.id, repoId: item.repoId, @@ -1831,6 +2083,7 @@ function PRReviewCell({ [repoOwnerSettings, sourceContext] ) const reviewerInputRef = useRef(null) + const reviewerTriggerRef = useRef(null) const reviewerInputFocusFrameRef = useRef(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({ event.stopPropagation()} onOpenAutoFocus={(event) => { event.preventDefault() @@ -2256,7 +2545,7 @@ function PRReviewCell({ }} />
-
+
{reviewerMetadata.loading ? (
{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) )} > @@ -2551,7 +2840,7 @@ function PRMergeCell({ )} > {merging ? ( - + ) : ( )} @@ -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): void => { - const next = event.target.value - setTaskSearchInput(next) - setActiveTaskPreset(null) - }, []) + const handleTaskSearchChange = useCallback( + (event: React.ChangeEvent): void => { + const next = event.target.value + const scoped = scopeGitHubTaskSearch(next, activeGithubTaskKind) + setTaskSearchInput(next) + setActiveTaskPreset(null) + // Why: the visible rows are keyed by appliedTaskSearch, not the draft + // input. Hide stale rows as soon as the draft would change the query. + setTasksFiltering(scoped !== appliedTaskSearch) + }, + [activeGithubTaskKind, appliedTaskSearch] + ) const handleSetDefaultTaskPreset = useCallback( (presetId: TaskViewPresetId): void => { @@ -7884,7 +8181,7 @@ export default function TaskPage(): React.JSX.Element {
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() + 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( + () => + 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( () => [ ...paletteSections.visibleWorktreeItems, + ...paletteSections.visibleProjectTargetItems, ...paletteSections.visibleMiddleItems, ...paletteSections.visibleOpenTabItems ], @@ -864,11 +957,17 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const listEntries = useMemo(() => { 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 { )} )} - + {entry.match.displayNameRange ? ( 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' + )} + > +
+
+
+
+
+
+ + {result.title} + + + {badgeLabel} + +
+
+ {isProject ? ( +
+ + + + {result.repo.displayName} + +
+ ) : null} +
+
+ + ) + } + if (entry.type === 'settings' || entry.type === 'quick-action') { const result = entry.result const Icon = result.icon diff --git a/src/renderer/src/components/cmd-j/palette-project-results.ts b/src/renderer/src/components/cmd-j/palette-project-results.ts new file mode 100644 index 00000000000..e4b26441bde --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-project-results.ts @@ -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 +}): 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() + 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 +}): 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 +}): 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) +} diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index d6e0257640e..1d034b1a507 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -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([]) + }) +}) diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index b5f6f0b3446..00fdc450bbf 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -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() const combinedDiffScrollTopCache = new Map() +const combinedDiffScrollAnchorCache = new Map() 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(null) + const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0) + const scrollAnchorRef = useRef( + combinedDiffScrollAnchorCache.get(viewStateKey) ?? null + ) + const latestDomScrollAnchorRef = useRef( + combinedDiffScrollAnchorCache.get(viewStateKey) ?? null + ) + const directScrollInputUntilRef = useRef(0) const activeScrollbarDragCleanupRef = useRef(null) const loadedIndicesRef = useRef>(new Set()) const loadingIndicesRef = useRef>(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('[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 = { + 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 => { @@ -1592,12 +1833,11 @@ export default function CombinedDiffViewer({
{skippedConflictNotice} -
+
{virtualizer.getVirtualItems().map((virtualItem) => { const section = sections[virtualItem.index] if (!section) { @@ -1608,6 +1848,8 @@ export default function CombinedDiffViewer({
vi.fn()) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: reportCrashMock +})) + +const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function createContainer(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + return { container, root: createRoot(container) } +} + +function BoundaryHarness({ children }: { children: ReactNode }): ReactElement { + return ( + + Loading...
}>{children} + + ) +} + +async function flushReactWork(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +describe('RecoverableRenderErrorBoundary lazy chunk containment', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + let consoleError: ReturnType + + beforeEach(() => { + reportCrashMock.mockReset() + window.sessionStorage.clear() + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + window.sessionStorage.clear() + consoleError.mockRestore() + }) + + it('renders the fallback without reporting after guarded dynamic import exhaustion', async () => { + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const LazyRejectingImport = lazyWithRetry( + () => + Promise.reject( + new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js') + ), + { retries: 0 } + ) + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + await flushReactWork() + await flushReactWork() + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + expect(reportCrashMock).not.toHaveBeenCalled() + }) + + it('still reports ordinary render errors', async () => { + const error = new Error('ordinary render failure') + function BrokenSurface(): ReactElement { + throw error + } + ;({ container, root } = createContainer()) + + await act(async () => { + root?.render( + + + + ) + }) + + expect(container?.querySelector('[role="alert"]')).not.toBeNull() + expect(reportCrashMock).toHaveBeenCalledTimes(1) + expect(reportCrashMock).toHaveBeenCalledWith( + expect.objectContaining({ + boundaryId: 'page.automations', + surface: 'page', + error + }) + ) + }) +}) diff --git a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx index afa7fbf3dc9..eddfbd14252 100644 --- a/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx +++ b/src/renderer/src/components/error-boundaries/RecoverableRenderErrorBoundary.tsx @@ -2,6 +2,7 @@ import React from 'react' import { AlertTriangle, RotateCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { isLazyChunkLoadError } from '@/lib/lazy-with-retry' import { reportReactErrorBoundaryCrash } from '@/lib/react-error-boundary-reporting' import type { ReactErrorBoundaryReportArgs } from '../../../../shared/crash-reporting' import { translate } from '@/i18n/i18n' @@ -48,6 +49,9 @@ export class RecoverableRenderErrorBoundary extends React.Component s.settings?.agentCmdOverrides ?? {}) const [query, setQuery] = useState('') - const [scope, setScope] = useState('project') + const [scope, setScope] = useState(DEFAULT_AI_VAULT_SCOPE) const [sort, setSort] = useState('updated') const [group, setGroup] = useState('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(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 => { 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) }, []) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx index f9feda064f4..0b42428f6a0 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -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, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts index 72be0b95b12..dfeedfcfb00 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.test.ts @@ -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() + }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts index 5f564cdcdcd..5890fcf97e7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-state.ts @@ -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 } diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index 1613f3c8b54..5fe28efa4be 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -1,19 +1,39 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { refreshGitStatusForWorktree, type GitStatusRefreshDeps } from './git-status-refresh' -import type { GitStatusResult } from '../../../../shared/types' +import { + clearGitStatusRefreshOrderingForTests, + refreshGitStatusForWorktree, + refreshGitStatusForWorktreeStrict, + type GitStatusRefreshDeps +} from './git-status-refresh' +import type { GitStatusResult, GitUpstreamStatus } from '../../../../shared/types' function makeDeps(): GitStatusRefreshDeps { return { setGitStatus: vi.fn(), updateWorktreeGitIdentity: vi.fn(), setUpstreamStatus: vi.fn(), - fetchUpstreamStatus: vi.fn().mockResolvedValue(undefined) + fetchUpstreamStatus: vi.fn().mockResolvedValue(null) } } +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + describe('refreshGitStatusForWorktree', () => { beforeEach(() => { vi.unstubAllGlobals() + clearGitStatusRefreshOrderingForTests() }) it('stores status, branch identity, and upstream data from git status', async () => { @@ -77,7 +97,8 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, { - runtimeTargetSettings: undefined + runtimeTargetSettings: undefined, + applyUpstreamStatus: false }) }) @@ -106,7 +127,8 @@ describe('refreshGitStatusForWorktree', () => { }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, { - runtimeTargetSettings: undefined + runtimeTargetSettings: undefined, + applyUpstreamStatus: false }) }) @@ -133,6 +155,130 @@ describe('refreshGitStatusForWorktree', () => { expect(deps.setGitStatus).toHaveBeenCalledWith('wt-3', status) }) + it('bypasses automatic no-upstream backoff only for strict refreshes', async () => { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-normal', + worktreePath: '/repo', + deps + }) + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps + }) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + + it('does not let an older automatic upstream result overwrite a strict result', async () => { + const automaticStatus = deferred() + const strictStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + } + const staleAutomaticStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi + .fn() + .mockReturnValueOnce(automaticStatus.promise) + .mockResolvedValueOnce(strictStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + + const automatic = refreshGitStatusForWorktree({ + worktreeId: 'wt-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(gitStatus).toHaveBeenCalledTimes(1)) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-race', + worktreePath: '/repo', + deps + }) + automaticStatus.resolve(staleAutomaticStatus) + await automatic + + expect(deps.setGitStatus).toHaveBeenCalledTimes(1) + expect(deps.setGitStatus).toHaveBeenCalledWith('wt-race', strictStatus) + expect(deps.updateWorktreeGitIdentity).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledWith('wt-race', strictStatus.upstreamStatus) + }) + + it('does not let an older automatic explicit upstream fetch overwrite a strict result', async () => { + const automaticFetch = deferred() + const strictStatus: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { + hasUpstream: true, + upstreamName: 'origin/feature', + ahead: 0, + behind: 1 + } + } + const staleAutomaticUpstream: GitUpstreamStatus = { hasUpstream: false, ahead: 0, behind: 0 } + const gitStatus = vi + .fn() + .mockResolvedValueOnce({ + entries: [], + conflictOperation: 'unknown' + } satisfies GitStatusResult) + .mockResolvedValueOnce(strictStatus) + vi.stubGlobal('window', { api: { git: { status: gitStatus } } }) + const deps = makeDeps() + vi.mocked(deps.fetchUpstreamStatus).mockReturnValueOnce(automaticFetch.promise) + + const automatic = refreshGitStatusForWorktree({ + worktreeId: 'wt-fetch-race', + worktreePath: '/repo', + deps + }) + await vi.waitFor(() => expect(deps.fetchUpstreamStatus).toHaveBeenCalledTimes(1)) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-fetch-race', + worktreePath: '/repo', + deps + }) + automaticFetch.resolve(staleAutomaticUpstream) + await automatic + + expect(deps.setUpstreamStatus).toHaveBeenCalledTimes(1) + expect(deps.setUpstreamStatus).toHaveBeenCalledWith( + 'wt-fetch-race', + strictStatus.upstreamStatus + ) + }) + it('clears stale branch identity when git status reports detached HEAD', async () => { const status: GitStatusResult = { entries: [], diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 89d4ec4f662..4c5b8fb7ace 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -18,8 +18,94 @@ export type GitStatusRefreshDeps = { worktreePath: string, connectionId?: string, pushTarget?: GitPushTarget, - options?: { runtimeTargetSettings?: Pick | null } - ) => Promise + options?: { + runtimeTargetSettings?: Pick | null + applyUpstreamStatus?: boolean + } + ) => Promise +} + +const MAX_REFRESH_ORDERING_WORKTREES = 1024 +const strictUpstreamRefreshGenerationByWorktree = new Map() +const automaticUpstreamRefreshInFlightByWorktree = new Map() + +function trimRefreshOrderingState(): void { + for (const worktreeId of strictUpstreamRefreshGenerationByWorktree.keys()) { + if (strictUpstreamRefreshGenerationByWorktree.size <= MAX_REFRESH_ORDERING_WORKTREES) { + break + } + if (automaticUpstreamRefreshInFlightByWorktree.has(worktreeId)) { + continue + } + strictUpstreamRefreshGenerationByWorktree.delete(worktreeId) + } +} + +function beginAutomaticUpstreamRefresh(worktreeId: string): number { + automaticUpstreamRefreshInFlightByWorktree.set( + worktreeId, + (automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0) + 1 + ) + return strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0 +} + +function finishAutomaticUpstreamRefresh(worktreeId: string): void { + const count = automaticUpstreamRefreshInFlightByWorktree.get(worktreeId) ?? 0 + if (count <= 1) { + automaticUpstreamRefreshInFlightByWorktree.delete(worktreeId) + } else { + automaticUpstreamRefreshInFlightByWorktree.set(worktreeId, count - 1) + } + trimRefreshOrderingState() +} + +function shouldApplyAutomaticUpstreamRefresh(worktreeId: string, startGeneration: number): boolean { + return (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) === startGeneration +} + +async function fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration +}: { + settings?: Pick | null + worktreeId: string + worktreePath: string + connectionId?: string + pushTarget?: GitPushTarget + deps: GitStatusRefreshDeps + startGeneration: number +}): Promise { + const upstreamStatus = await deps.fetchUpstreamStatus( + worktreeId, + worktreePath, + connectionId, + pushTarget, + { + runtimeTargetSettings: settings, + applyUpstreamStatus: false + } + ) + if (upstreamStatus && shouldApplyAutomaticUpstreamRefresh(worktreeId, startGeneration)) { + deps.setUpstreamStatus(worktreeId, upstreamStatus) + } +} + +function beginStrictUpstreamRefresh(worktreeId: string): void { + strictUpstreamRefreshGenerationByWorktree.set( + worktreeId, + (strictUpstreamRefreshGenerationByWorktree.get(worktreeId) ?? 0) + 1 + ) + trimRefreshOrderingState() +} + +export function clearGitStatusRefreshOrderingForTests(): void { + strictUpstreamRefreshGenerationByWorktree.clear() + automaticUpstreamRefreshInFlightByWorktree.clear() } export async function refreshGitStatusForWorktree({ @@ -37,51 +123,77 @@ export async function refreshGitStatusForWorktree({ pushTarget?: GitPushTarget deps: GitStatusRefreshDeps }): Promise { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + const upstreamStartGeneration = beginAutomaticUpstreamRefresh(worktreeId) + try { + const status = (await getRuntimeGitStatus({ + settings, + worktreeId, + worktreePath, + connectionId + })) as GitStatusResult - deps.setGitStatus(worktreeId, status) - // Why: branch switches can happen inside a terminal. `git status --branch` - // gives us the new identity without a separate worktree-list poll. - deps.updateWorktreeGitIdentity(worktreeId, { - head: status.head, - // Why: detached HEAD reports a head oid and no branch. Pass null as an - // explicit clear signal so stale branch names don't linger in the UI. - branch: status.branch ?? (status.head ? null : undefined) - }) - if (pushTarget) { - // Why: porcelain status reports Git's configured upstream. Source Control - // actions for PR-created worktrees must instead reconcile with Orca's - // explicit publish target. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { - runtimeTargetSettings: settings + if (!shouldApplyAutomaticUpstreamRefresh(worktreeId, upstreamStartGeneration)) { + return + } + + deps.setGitStatus(worktreeId, status) + // Why: branch switches can happen inside a terminal. `git status --branch` + // gives us the new identity without a separate worktree-list poll. + deps.updateWorktreeGitIdentity(worktreeId, { + head: status.head, + // Why: detached HEAD reports a head oid and no branch. Pass null as an + // explicit clear signal so stale branch names don't linger in the UI. + branch: status.branch ?? (status.head ? null : undefined) }) - return - } - if (status.upstreamStatus) { - if ( - status.upstreamStatus.ahead > 0 && - status.upstreamStatus.behind > 0 && - status.upstreamStatus.behindCommitsArePatchEquivalent === undefined - ) { - // Why: porcelain status has counts but cannot tell stale post-rebase - // upstream commits from real remote work. Writing it first makes the - // primary action flicker between Sync and Force Push on every poll. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { - runtimeTargetSettings: settings + if (pushTarget) { + // Why: porcelain status reports Git's configured upstream. Source Control + // actions for PR-created worktrees must instead reconcile with Orca's + // explicit publish target. + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration: upstreamStartGeneration }) return } - deps.setUpstreamStatus(worktreeId, status.upstreamStatus) - return + if (status.upstreamStatus) { + if ( + status.upstreamStatus.ahead > 0 && + status.upstreamStatus.behind > 0 && + status.upstreamStatus.behindCommitsArePatchEquivalent === undefined + ) { + // Why: porcelain status has counts but cannot tell stale post-rebase + // upstream commits from real remote work. Writing it first makes the + // primary action flicker between Sync and Force Push on every poll. + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + deps, + startGeneration: upstreamStartGeneration + }) + return + } + deps.setUpstreamStatus(worktreeId, status.upstreamStatus) + return + } + await fetchAndApplyAutomaticUpstreamStatus({ + settings, + worktreeId, + worktreePath, + connectionId, + pushTarget, + deps, + startGeneration: upstreamStartGeneration + }) + } finally { + finishAutomaticUpstreamRefresh(worktreeId) } - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { - runtimeTargetSettings: settings - }) } export async function refreshGitStatusForWorktreeStrict({ @@ -101,12 +213,20 @@ export async function refreshGitStatusForWorktreeStrict({ fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus'] } }): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> { - const status = (await getRuntimeGitStatus({ - settings, - worktreeId, - worktreePath, - connectionId - })) as GitStatusResult + beginStrictUpstreamRefresh(worktreeId) + const status = (await getRuntimeGitStatus( + { + settings, + worktreeId, + worktreePath, + connectionId + }, + { + // Why: strict refreshes are user-triggered reconciliation and must not reuse + // automatic polling's no-upstream backoff window. + bypassEffectiveUpstreamNegativeCache: true + } + )) as GitStatusResult deps.setGitStatus(worktreeId, status) // Why: branch switches can happen inside a terminal. `git status --branch` diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts index 9f045022168..c534a7859f7 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.test.ts @@ -169,7 +169,8 @@ describe('useGitStatusPolling', () => { undefined, undefined, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + applyUpstreamStatus: false } ) }) @@ -192,7 +193,8 @@ describe('useGitStatusPolling', () => { undefined, pushTarget, { - runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + runtimeTargetSettings: { activeRuntimeEnvironmentId: null }, + applyUpstreamStatus: false } ) }) diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index 63dc178eab7..26ec195d440 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -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') }) - 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') }) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx index 13be2021c7d..ba9022ca742 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.test.tsx @@ -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(/]*disabled=""[^>]*>No, import separately<\/button>/) - expect(html).toMatch(/]*disabled=""[^>]*>Yes, import as monorepo<\/button>/) + expect(html).toMatch(/]*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() }) }) diff --git a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx index 4de4a9b469f..ac7db3b2958 100644 --- a/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoNestedImportStep.tsx @@ -105,13 +105,13 @@ export function AddRepoNestedImportStep({

{translate( 'auto.components.sidebar.AddRepoNestedImportStep.fb33359f69', - 'Is this a monorepo?' + 'Group these repositories?' )}

{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.' )}

@@ -156,7 +156,7 @@ export function AddRepoNestedImportStep({ {showGroupSpinner ? : null} {translate( 'auto.components.sidebar.AddRepoNestedImportStep.a0bc4d1f8e', - 'Import as group' + 'Yes, import as group' )}
diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 7d21c357afd..9927e4e9db2 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -13,10 +13,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { searchRepos } from '@/lib/repo-search' import { cn } from '@/lib/utils' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' @@ -37,6 +39,9 @@ const SidebarFilter = React.memo(function SidebarFilter({ }: SidebarFilterProps) { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const setShowSleepingWorkspaces = useAppStore((s) => s.setShowSleepingWorkspaces) + // Surface the user-assigned shortcut here so the filter menu doubles as its + // discovery point ('Unassigned' until they bind one in Settings → Shortcuts). + const sleepingShortcut = useShortcutLabel('sidebar.sleepingWorkspaces.toggle') const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) @@ -183,6 +188,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ label={translate('auto.components.sidebar.SidebarFilter.638a2d221d', 'Hide sleeping')} checked={!showSleepingWorkspaces} onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} + shortcutLabel={sleepingShortcut === 'Unassigned' ? undefined : sleepingShortcut} /> } @@ -333,12 +339,14 @@ function FilterToggleRow({ icon, label, checked, - onChange + onChange, + shortcutLabel }: { icon: React.ReactNode label: string checked: boolean onChange: (next: boolean) => void + shortcutLabel?: string }) { return ( ) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a46ed4c2d47..1236d6dad5c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -78,6 +78,7 @@ import { ALL_GROUP_KEY, PINNED_GROUP_KEY, buildRows, + getProjectGroupHeaderKey, getGroupKeysForWorktree, getLineageGroupKey } from './worktree-list-groups' @@ -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, + projectGrouping?: ProjectGroupingModel +): string[] { + if (rowKey.startsWith('repo:')) { + return [rowKey.slice('repo:'.length)] + } + const setupMarker = '::setup:' + const setupIndex = rowKey.indexOf(setupMarker) + if (rowKey.startsWith('project:') && setupIndex !== -1) { + return [rowKey.slice(setupIndex + setupMarker.length)] + } + const projectId = getProjectIdFromHeaderRowKey(rowKey) + if (!projectId) { + return [] + } + const repoIds = new Set() + for (const setup of projectGrouping?.projectHostSetups ?? []) { + if (setup.projectId === projectId && repoMap.has(setup.repoId)) { + repoIds.add(setup.repoId) + } + } + const project = projectGrouping?.projects.find((candidate) => candidate.id === projectId) + for (const repoId of project?.sourceRepoIds ?? []) { + if (repoMap.has(repoId)) { + repoIds.add(repoId) + } + } + return [...repoIds] +} + +function getProjectGroupAncestorKeys( + projectGroupId: string | null | undefined, + projectGroups: readonly ProjectGroup[] +): string[] { + const groupsById = new Map(projectGroups.map((group) => [group.id, group])) + const keys: string[] = [] + const seen = new Set() + let currentGroupId = projectGroupId ?? null + while (currentGroupId && !seen.has(currentGroupId)) { + const group = groupsById.get(currentGroupId) + if (!group) { + break + } + seen.add(currentGroupId) + keys.unshift(getProjectGroupHeaderKey(group.id)) + currentGroupId = group.parentGroupId + } + return keys +} + +function getSidebarRowRevealAncestorKeys(args: { + rowKey: string + repoMap: Map + projectGroups: readonly ProjectGroup[] + projectGrouping?: ProjectGroupingModel +}): string[] { + if (args.rowKey.startsWith('project-group:')) { + const groupId = args.rowKey.slice('project-group:'.length) + const group = args.projectGroups.find((candidate) => candidate.id === groupId) + return getProjectGroupAncestorKeys(group?.parentGroupId, args.projectGroups) + } + const keys = new Set() + for (const repoId of getRepoIdsFromHeaderRowKey( + args.rowKey, + args.repoMap, + args.projectGrouping + )) { + const repo = args.repoMap.get(repoId) + for (const key of getProjectGroupAncestorKeys(repo?.projectGroupId, args.projectGroups)) { + keys.add(key) + } + } + return [...keys] +} + function getWorktreeVisibilityMenuLabel(repo: Repo): string { const visibility = effectiveExternalWorktreeVisibility( repo, @@ -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( - null - ) + const [highlightedRevealRowKey, setHighlightedRevealRowKey] = useState(null) const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) const worktreeDragSessionRef = useRef(null) @@ -1156,6 +1283,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const worktreeNativeAutoscrollLastFrameTimeRef = useRef(null) const worktreeNativeLatestPointRef = useRef(null) const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null) + const pendingRowRevealRetryRef = useRef<{ rowKey: string; count: number } | null>(null) const pendingRevealFrameIdsRef = useRef>(new Set()) const revealHighlightFrameIdRef = useRef(null) const revealHighlightTimeoutRef = useRef(null) @@ -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 } >
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() + 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} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 81a78bec138..25165df25b8 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -98,6 +98,7 @@ function filterState(overrides: Partial = {}): FilterState { filterRepoIds: [], hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + workspaceHostScope: 'all', ...overrides } } @@ -617,6 +618,16 @@ describe('computeClearFilterActions', () => { expect(actions.resetFilterRepoIds).toBe(true) }) + it('flags legacy single-host scope for reset even without visible host ids', () => { + expect(computeClearFilterActions(filterState({ workspaceHostScope: 'ssh:host-1' }))).toEqual({ + resetShowSleepingWorkspaces: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: false, + resetVisibleWorkspaceHostIds: true + }) + }) + it('flags every active filter simultaneously', () => { expect( computeClearFilterActions( diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 785fc2a39fa..434df0ee0de 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -36,6 +36,7 @@ export type SidebarFilterState = { hideDefaultBranchWorkspace: boolean hideAutomationGeneratedWorkspaces: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null + workspaceHostScope?: ExecutionHostScope } /** @@ -53,7 +54,8 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.filterRepoIds.length > 0 || state.hideDefaultBranchWorkspace || state.hideAutomationGeneratedWorkspaces || - state.visibleWorkspaceHostIds != null + state.visibleWorkspaceHostIds != null || + (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) ) } @@ -83,7 +85,9 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetFilterRepoIds: state.filterRepoIds.length > 0, resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, - resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null + resetVisibleWorkspaceHostIds: + state.visibleWorkspaceHostIds != null || + (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) } } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 2b5a89f18e2..91f9aa5c8b3 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -145,14 +145,24 @@ type ProjectGroupingIndex = { multiSetupProjectHostKeys: Set } +const projectGroupingIndexCache = new WeakMap() + function projectHostKey(projectId: string, hostId: string): string { return `${projectId}::${hostId}` } function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { - const projects = model?.projects ?? [] - const projectHostSetups = model?.projectHostSetups ?? [] + if (!model) { + return null + } + const cached = projectGroupingIndexCache.get(model) + if (cached !== undefined) { + return cached + } + const projects = model.projects ?? [] + const projectHostSetups = model.projectHostSetups ?? [] if (projects.length === 0 || projectHostSetups.length === 0) { + projectGroupingIndexCache.set(model, null) return null } const setupCountByProjectHost = new Map() @@ -166,18 +176,27 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin multiSetupProjectHostKeys.add(key) } } - return { + const index = { projectById: new Map(projects.map((project) => [project.id, project])), setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), multiSetupProjectHostKeys } + projectGroupingIndexCache.set(model, index) + return index +} + +export type ProjectHeaderRevealTarget = { + key: string + label: string + repo?: Repo + projectId?: string } function getProjectGroupingForRepo( repoId: string, repoMap: Map, projectIndex: ProjectGroupingIndex | null -): { key: string; label: string; repo?: Repo; projectId?: string } { +): ProjectHeaderRevealTarget { const repo = repoMap.get(repoId) const setup = projectIndex?.setupByRepoId.get(repoId) const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined @@ -207,6 +226,14 @@ function getProjectGroupingForRepo( } } +export function getProjectHeaderRevealTarget( + repoId: string, + repoMap: Map, + projectGrouping?: ProjectGroupingModel +): ProjectHeaderRevealTarget { + return getProjectGroupingForRepo(repoId, repoMap, buildProjectGroupingIndex(projectGrouping)) +} + function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { group.repoIds.add(repoId) } diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index 6a1efe52481..453096115b0 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -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( diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 83d6b598163..8515942da7f 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -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) diff --git a/src/renderer/src/components/task-page-github-status-actions.test.ts b/src/renderer/src/components/task-page-github-status-actions.test.ts new file mode 100644 index 00000000000..b18226f6f88 --- /dev/null +++ b/src/renderer/src/components/task-page-github-status-actions.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + buildTaskPageGitHubCloseUpdate, + getTaskPageGitHubDuplicateCandidates, + getTaskPageGitHubDuplicateTargetErrorMessage, + validateTaskPageGitHubDuplicateTarget +} from './task-page-github-status-actions' +import type { GitHubWorkItem } from '../../../shared/types' + +describe('TaskPage GitHub status actions', () => { + it('builds completed and not planned close updates', () => { + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'completed' })).toEqual({ + state: 'closed', + stateReason: 'completed' + }) + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'not_planned' })).toEqual({ + state: 'closed', + stateReason: 'not_planned' + }) + }) + + it('builds duplicate close updates with a target issue number', () => { + expect(buildTaskPageGitHubCloseUpdate({ stateReason: 'duplicate', duplicateOf: 42 })).toEqual({ + state: 'closed', + stateReason: 'duplicate', + duplicateOf: 42 + }) + }) + + it('validates duplicate targets before dispatch', () => { + expect(validateTaskPageGitHubDuplicateTarget('', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('12', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('12.5', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('-1', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget('0', 12).ok).toBe(false) + expect(validateTaskPageGitHubDuplicateTarget(' 34 ', 12)).toEqual({ + ok: true, + duplicateOf: 34 + }) + }) + + it('maps duplicate validation failures to localized messages', () => { + const t = (_key: string, fallback: string): string => fallback + const validation = validateTaskPageGitHubDuplicateTarget('same', 12) + + expect(validation.ok).toBe(false) + if (!validation.ok) { + expect(getTaskPageGitHubDuplicateTargetErrorMessage(validation, t)).toBe( + 'Use a whole issue number.' + ) + } + }) + + it('filters duplicate candidates to other matching issues', () => { + const items = [ + buildWorkItem({ number: 12, title: 'Current issue', type: 'issue' }), + buildWorkItem({ number: 34, title: 'Copy SSH file support', type: 'issue' }), + buildWorkItem({ number: 56, title: 'Unrelated pull request', type: 'pr' }), + buildWorkItem({ number: 78, title: 'Windows path copy bug', type: 'issue' }) + ] + + expect(getTaskPageGitHubDuplicateCandidates(items, 12, '').map((item) => item.number)).toEqual([ + 34, 78 + ]) + expect( + getTaskPageGitHubDuplicateCandidates(items, 12, 'copy').map((item) => item.number) + ).toEqual([34, 78]) + expect( + getTaskPageGitHubDuplicateCandidates(items, 12, '34').map((item) => item.number) + ).toEqual([34]) + }) +}) + +function buildWorkItem(overrides: Partial): GitHubWorkItem { + return { + id: `item-${overrides.number ?? 1}`, + type: 'issue', + number: 1, + title: 'Issue', + state: 'open', + url: 'https://github.com/stablyai/orca/issues/1', + labels: [], + updatedAt: '2026-06-22T00:00:00.000Z', + author: null, + repoId: 'repo-1', + ...overrides + } +} diff --git a/src/renderer/src/components/task-page-github-status-actions.ts b/src/renderer/src/components/task-page-github-status-actions.ts new file mode 100644 index 00000000000..1d3ed687767 --- /dev/null +++ b/src/renderer/src/components/task-page-github-status-actions.ts @@ -0,0 +1,102 @@ +import type { + GitHubIssueCloseReason, + GitHubIssueUpdate, + GitHubWorkItem +} from '../../../shared/types' + +export type TaskPageGitHubCloseAction = + | { stateReason: Exclude } + | { stateReason: 'duplicate'; duplicateOf: number } + +export type TaskPageGitHubDuplicateValidation = + | { ok: true; duplicateOf: number } + | { + ok: false + reason: 'missing' | 'not_integer' | 'not_positive' | 'same_issue' + } + +export type TaskPageGitHubDuplicateValidationError = Extract< + TaskPageGitHubDuplicateValidation, + { ok: false } +> + +type TranslateDuplicateError = (key: string, fallback: string) => string + +export function buildTaskPageGitHubCloseUpdate( + action: TaskPageGitHubCloseAction +): GitHubIssueUpdate { + return { + state: 'closed', + stateReason: action.stateReason, + ...(action.stateReason === 'duplicate' ? { duplicateOf: action.duplicateOf } : {}) + } +} + +export function validateTaskPageGitHubDuplicateTarget( + value: string, + currentIssueNumber: number +): TaskPageGitHubDuplicateValidation { + const trimmed = value.trim() + if (!trimmed) { + return { ok: false, reason: 'missing' } + } + if (!/^\d+$/.test(trimmed)) { + return { ok: false, reason: 'not_integer' } + } + const duplicateOf = Number(trimmed) + if (!Number.isSafeInteger(duplicateOf) || duplicateOf <= 0) { + return { ok: false, reason: 'not_positive' } + } + if (duplicateOf === currentIssueNumber) { + return { ok: false, reason: 'same_issue' } + } + return { ok: true, duplicateOf } +} + +export function getTaskPageGitHubDuplicateTargetErrorMessage( + validation: TaskPageGitHubDuplicateValidationError, + translate: TranslateDuplicateError +): string { + switch (validation.reason) { + case 'missing': + return translate( + 'auto.components.TaskPage.duplicateIssueMissing', + 'Enter an issue number in this repository.' + ) + case 'not_integer': + return translate( + 'auto.components.TaskPage.duplicateIssueNotInteger', + 'Use a whole issue number.' + ) + case 'not_positive': + return translate( + 'auto.components.TaskPage.duplicateIssueNotPositive', + 'Use a positive issue number.' + ) + case 'same_issue': + return translate( + 'auto.components.TaskPage.duplicateIssueSameIssue', + 'Choose a different issue.' + ) + } +} + +export function getTaskPageGitHubDuplicateCandidates( + items: GitHubWorkItem[], + currentIssueNumber: number, + query: string +): GitHubWorkItem[] { + const normalizedQuery = query.trim().toLowerCase() + return items.filter((candidate) => { + if (candidate.type !== 'issue' || candidate.number === currentIssueNumber) { + return false + } + if (!normalizedQuery) { + return true + } + return ( + candidate.title.toLowerCase().includes(normalizedQuery) || + String(candidate.number).includes(normalizedQuery) + ) + }) +} diff --git a/src/renderer/src/components/task-page-github-work-item-status.ts b/src/renderer/src/components/task-page-github-work-item-status.ts index ab9d0361032..b200abb2328 100644 --- a/src/renderer/src/components/task-page-github-work-item-status.ts +++ b/src/renderer/src/components/task-page-github-work-item-status.ts @@ -55,7 +55,6 @@ export function getTaskPageGitHubPRIconTone(item: GitHubWorkItemStatusItem): str return 'text-muted-foreground' } - // Exhaustive switch ensures TypeScript will error if new states are added switch (item.state) { case 'draft': return 'text-muted-foreground' diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts index ddb842b66f1..b4d12bfb12f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts @@ -294,16 +294,80 @@ describe('createTerminalHandleLinkProvider', () => { }) }) + it('uses the owning runtime for terminal links when a renderer match belongs to another runtime', async () => { + markRuntimeEnvironmentCompatible('env-1') + mocks.storeState.tabsByWorktree = { + 'wt-local': [ + { + id: 'tab-local', + worktreeId: 'wt-local', + ptyId: 'term_worker', + title: 'Local worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ], + 'wt-env-2': [ + { + id: 'tab-env-2', + worktreeId: 'wt-env-2', + ptyId: 'remote:env-2@@term_worker', + title: 'Other remote worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.ptyIdsByTabId = { + 'tab-local': ['term_worker'], + 'tab-env-2': ['remote:env-2@@term_worker'] + } + mocks.storeState.terminalLayoutsByTabId = { + 'tab-local': { root: null, activeLeafId: null, expandedLeafId: null }, + 'tab-env-2': { root: null, activeLeafId: null, expandedLeafId: null } + } + window.api.runtimeEnvironments.call = vi.fn().mockResolvedValue({ + ok: true, + result: { focus: { handle: 'term_worker', tabId: 'tab-env-1', worktreeId: 'wt-env-1' } } + }) + const links = await collectLinks([makeBufferLine('Worker: term_worker')], 1, 'env-1') + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-local') + expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-env-2') + expect(window.api.runtimeEnvironments.call).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'terminal.focus', + params: { terminal: 'term_worker' }, + timeoutMs: undefined + }) + }) + it('provides wrapped task links and focuses their dispatched terminal through runtime', async () => { const rows = [makeBufferLine('Task: task_work'), makeBufferLine('er', { isWrapped: true })] const links = await collectLinks(rows, 1) + const preventDefault = vi.fn() expect(links.map((link) => link.text)).toEqual(['task_worker']) links[0].activate( { metaKey: true, ctrlKey: false, - preventDefault: vi.fn() + preventDefault } as unknown as MouseEvent, links[0].text ) @@ -318,6 +382,179 @@ describe('createTerminalHandleLinkProvider', () => { method: 'terminal.focus', params: { terminal: 'term_worker' } }) + expect(preventDefault).toHaveBeenCalled() + }) + + it('focuses resolved task terminals directly when they are already mounted', async () => { + mocks.storeState.tabsByWorktree = { + 'wt-1': [ + { + id: 'tab-1', + worktreeId: 'wt-1', + ptyId: 'term_worker', + title: 'Worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.ptyIdsByTabId = { 'tab-1': ['term_worker'] } + mocks.storeState.terminalLayoutsByTabId = { + 'tab-1': { root: null, activeLeafId: null, expandedLeafId: null } + } + const links = await collectLinks([makeBufferLine('Task: task_worker')]) + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + + expect(window.api.runtime.call).toHaveBeenCalledTimes(1) + expect(window.api.runtime.call).toHaveBeenCalledWith({ + method: 'orchestration.dispatchShow', + params: { task: 'task_worker' } + }) + expect(mocks.storeState.setActiveWorktree).toHaveBeenCalledWith('wt-1') + expect(mocks.storeState.markWorktreeVisited).toHaveBeenCalledWith('wt-1') + expect(mocks.storeState.setActiveView).toHaveBeenCalledWith('terminal') + expect(mocks.storeState.setActiveTabType).toHaveBeenCalledWith('terminal') + expect(mocks.storeState.revealWorktreeInSidebar).toHaveBeenCalledWith('wt-1') + expect(mocks.storeState.setActiveTab).toHaveBeenCalledWith('tab-1') + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('tab-1') + }) + + it('focuses mounted task terminals only when they belong to the owning runtime', async () => { + markRuntimeEnvironmentCompatible('env-1') + mocks.storeState.tabsByWorktree = { + 'wt-remote': [ + { + id: 'tab-remote', + worktreeId: 'wt-remote', + ptyId: 'remote:env-1@@term_remote', + title: 'Remote worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.ptyIdsByTabId = { 'tab-remote': ['remote:env-1@@term_remote'] } + mocks.storeState.terminalLayoutsByTabId = { + 'tab-remote': { root: null, activeLeafId: null, expandedLeafId: null } + } + window.api.runtimeEnvironments.call = vi.fn().mockResolvedValue({ + ok: true, + result: { dispatch: { assignee_handle: 'term_remote' } } + }) + const links = await collectLinks([makeBufferLine('Task: task_remote')], 1, 'env-1') + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(window.api.runtimeEnvironments.call).toHaveBeenCalledTimes(1) + expect(window.api.runtimeEnvironments.call).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'orchestration.dispatchShow', + params: { task: 'task_remote' }, + timeoutMs: undefined + }) + expect(mocks.storeState.setActiveWorktree).toHaveBeenCalledWith('wt-remote') + expect(mocks.storeState.setActiveTab).toHaveBeenCalledWith('tab-remote') + }) + + it('falls back to the owning runtime when task handles collide across runtimes', async () => { + markRuntimeEnvironmentCompatible('env-1') + mocks.storeState.tabsByWorktree = { + 'wt-local': [ + { + id: 'tab-local', + worktreeId: 'wt-local', + ptyId: 'term_worker', + title: 'Local worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ], + 'wt-env-2': [ + { + id: 'tab-env-2', + worktreeId: 'wt-env-2', + ptyId: 'remote:env-2@@term_worker', + title: 'Other remote worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + mocks.storeState.ptyIdsByTabId = { + 'tab-local': ['term_worker'], + 'tab-env-2': ['remote:env-2@@term_worker'] + } + mocks.storeState.terminalLayoutsByTabId = { + 'tab-local': { root: null, activeLeafId: null, expandedLeafId: null }, + 'tab-env-2': { root: null, activeLeafId: null, expandedLeafId: null } + } + window.api.runtimeEnvironments.call = vi.fn().mockImplementation(({ method }) => { + if (method === 'orchestration.dispatchShow') { + return Promise.resolve({ + ok: true, + result: { dispatch: { assignee_handle: 'term_worker' } } + }) + } + return Promise.resolve({ + ok: true, + result: { focus: { handle: 'term_worker', tabId: 'tab-env-1', worktreeId: 'wt-env-1' } } + }) + }) + const links = await collectLinks([makeBufferLine('Task: task_worker')], 1, 'env-1') + + links[0].activate( + { + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as MouseEvent, + links[0].text + ) + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-local') + expect(mocks.storeState.setActiveTab).not.toHaveBeenCalledWith('tab-env-2') + expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(1, { + selector: 'env-1', + method: 'orchestration.dispatchShow', + params: { task: 'task_worker' }, + timeoutMs: undefined + }) + expect(window.api.runtimeEnvironments.call).toHaveBeenNthCalledWith(2, { + selector: 'env-1', + method: 'terminal.focus', + params: { terminal: 'term_worker' }, + timeoutMs: undefined + }) }) it('returns task and terminal links in line order', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index d692e68b897..c11d595f611 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -4,7 +4,7 @@ import { useAppStore } from '@/store' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' -import { getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-stream' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' import { extractOrchestrationTaskLinks, @@ -96,13 +96,14 @@ function findPrefixedTokenEnd(lineText: string, startIndex: number): number { export function findTerminalHandleTarget( handle: string, - state: TerminalHandleFocusState + state: TerminalHandleFocusState, + runtimeEnvironmentId?: string | null ): TerminalHandleTarget | null { for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { for (const tab of tabs) { const layout = state.terminalLayoutsByTabId[tab.id] for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { - if (ptyIdMatchesTerminalHandle(ptyId, handle)) { + if (ptyIdMatchesTerminalHandle(ptyId, handle, runtimeEnvironmentId)) { return { worktreeId, tabId: tab.id, leafId } } } @@ -110,7 +111,9 @@ export function findTerminalHandleTarget( const tabPtyIds = [tab.ptyId, ...(state.ptyIdsByTabId[tab.id] ?? [])].filter( (ptyId): ptyId is string => Boolean(ptyId) ) - if (tabPtyIds.some((ptyId) => ptyIdMatchesTerminalHandle(ptyId, handle))) { + if ( + tabPtyIds.some((ptyId) => ptyIdMatchesTerminalHandle(ptyId, handle, runtimeEnvironmentId)) + ) { return { worktreeId, tabId: tab.id, leafId: layout?.activeLeafId ?? null } } } @@ -118,9 +121,12 @@ export function findTerminalHandleTarget( return null } -export function focusRendererTerminalHandle(handle: string): boolean { +export function focusRendererTerminalHandle( + handle: string, + runtimeEnvironmentId?: string | null +): boolean { const store = useAppStore.getState() - const target = findTerminalHandleTarget(handle, store) + const target = findTerminalHandleTarget(handle, store, runtimeEnvironmentId) if (!target) { return false } @@ -211,21 +217,39 @@ async function activateParsedLink( ): Promise { try { if (parsed.kind === 'terminal') { - if (!focusRendererTerminalHandle(parsed.text)) { + if (!focusRendererTerminalHandle(parsed.text, runtimeEnvironmentId)) { await focusRuntimeTerminalHandle(parsed.text, runtimeEnvironmentId) } return } // Why: a task can be retried onto a new dispatch; runtime DB is the // authority for the latest terminal assigned to a stable task ID. - await focusRuntimeOrchestrationTask(parsed.text, runtimeEnvironmentId) + await focusRuntimeOrchestrationTask(parsed.text, runtimeEnvironmentId, (handle) => + focusRendererTerminalHandle(handle, runtimeEnvironmentId) + ) } catch (error: unknown) { console.warn('[terminal-handle-link] focus failed:', error) } } -function ptyIdMatchesTerminalHandle(ptyId: string, handle: string): boolean { - return ptyId === handle || getRemoteRuntimeTerminalHandle(ptyId) === handle +function ptyIdMatchesTerminalHandle( + ptyId: string, + handle: string, + runtimeEnvironmentId?: string | null +): boolean { + const targetEnvironmentId = runtimeEnvironmentId?.trim() || null + if (ptyId === handle) { + return targetEnvironmentId === null + } + const remotePty = parseRemoteRuntimePtyId(ptyId) + if (!remotePty || remotePty.handle !== handle) { + return false + } + const ptyEnvironmentId = remotePty.environmentId?.trim() || null + if (runtimeEnvironmentId === undefined) { + return true + } + return ptyEnvironmentId === targetEnvironmentId } function getTerminalHandleFocusHint(): string { diff --git a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts index 99acf41f12c..fdd90fb4019 100644 --- a/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-orchestration-task-links.ts @@ -46,7 +46,8 @@ export function extractOrchestrationTaskLinks(lineText: string): ParsedOrchestra export async function focusRuntimeOrchestrationTask( taskId: string, - runtimeEnvironmentId: string | null + runtimeEnvironmentId: string | null, + focusRendererTerminal?: (handle: string) => boolean ): Promise { const environmentId = runtimeEnvironmentId?.trim() const target = environmentId @@ -59,6 +60,9 @@ export async function focusRuntimeOrchestrationTask( if (!terminal) { throw new Error(`No dispatched terminal for orchestration task ${taskId}`) } + if (focusRendererTerminal?.(terminal)) { + return + } // Why: task IDs are stable orchestration DB records, but terminal.focus owns // the app-side navigation contract for local and SSH runtime terminals. await callRuntimeRpc(target, 'terminal.focus', { terminal }) diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts index 64a78e1ed7e..4d35ef7cfbe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.test.ts @@ -17,6 +17,21 @@ function makePaneContainer(acceptedElement: Element): Element { } as unknown as Element } +function makePaneContainerFor(acceptedElements: readonly Element[]): Element { + return { + contains: vi.fn((element: Element | null) => acceptedElements.includes(element as Element)) + } as unknown as Element +} + +function makeElement(tagName: string, classNames: string[] = []): Element { + return { + tagName, + classList: { + contains: (className: string) => classNames.includes(className) + } + } as unknown as Element +} + describe('terminal paste target state', () => { it('accepts the same mounted pane, transport, and PTY identity', () => { const transport = makeTransport() @@ -128,6 +143,51 @@ describe('terminal paste target state', () => { ).toBe(false) }) + it('keeps keyboard-owned paste current across transient document blur', () => { + const terminalInput = {} as Element + const body = makeElement('body') + const paneContainer = makePaneContainer(terminalInput) + + expect( + isTerminalPanePasteFocusCurrent({ + requireSameFocusedElement: true, + activeElementAtDispatch: terminalInput, + paneContainer, + activeElement: body + }) + ).toBe(true) + }) + + it('keeps keyboard-owned paste current when xterm replaces its helper textarea', () => { + const originalTerminalInput = makeElement('textarea', ['xterm-helper-textarea']) + const replacementTerminalInput = makeElement('textarea', ['xterm-helper-textarea']) + const paneContainer = makePaneContainerFor([originalTerminalInput, replacementTerminalInput]) + + expect( + isTerminalPanePasteFocusCurrent({ + requireSameFocusedElement: true, + activeElementAtDispatch: originalTerminalInput, + paneContainer, + activeElement: replacementTerminalInput + }) + ).toBe(true) + }) + + it('rejects keyboard-owned paste when focus moves to another control in the pane', () => { + const terminalInput = makeElement('textarea', ['xterm-helper-textarea']) + const searchInput = makeElement('input') + const paneContainer = makePaneContainerFor([terminalInput, searchInput]) + + expect( + isTerminalPanePasteFocusCurrent({ + requireSameFocusedElement: true, + activeElementAtDispatch: terminalInput, + paneContainer, + activeElement: searchInput + }) + ).toBe(false) + }) + it('does not require focus continuity for programmatic terminal paste', () => { const terminalInput = {} as Element const otherInput = {} as Element diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts index 42bf8cc07bc..1f42adfaae9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-target-state.ts @@ -54,9 +54,31 @@ export function isTerminalPanePasteFocusCurrent({ if (!requireSameFocusedElement || activeElementAtDispatch === null) { return true } - // Why: clipboard reads are async, so focus may leave the terminal before - // execution. In that case the stale terminal must not receive the payload. - return ( - activeElement === activeElementAtDispatch && paneContainer.contains(activeElementAtDispatch) - ) + if (!paneContainer.contains(activeElementAtDispatch)) { + return false + } + if (activeElement === activeElementAtDispatch) { + return true + } + // Why: macOS dictation and clipboard permission handoffs can transiently + // blur xterm to body, and xterm may replace its helper textarea mid-paste. + if (isInertDocumentFocus(activeElement)) { + return true + } + if (activeElement === paneContainer) { + return true + } + return paneContainer.contains(activeElement) && isXtermHelperTextarea(activeElement) +} + +function isInertDocumentFocus(element: Element | null): boolean { + if (!element) { + return true + } + const tagName = element.tagName?.toUpperCase() + return tagName === 'BODY' || tagName === 'HTML' +} + +function isXtermHelperTextarea(element: Element | null): boolean { + return element?.classList?.contains('xterm-helper-textarea') === true } diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 699675d8b3d..586acdab728 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -258,6 +258,13 @@ export type ComposerCardProps = { * rather than used as the base for a new branch. */ reuseSelectedBranch: boolean onReuseSelectedBranchChange: (next: boolean) => void + /** Whether the "create multiple" toggle is shown — worktree (git) targets + * only; folder-workspace targets create-and-close as before. */ + showCreateMultiple: boolean + /** When on, the modal stays open after each create and resets identity fields + * so the user can create several worktrees in a row. */ + createMultiple: boolean + onCreateMultipleChange: (next: boolean) => void agentPrompt: string onAgentPromptChange: (value: string) => void /** Rendered issueCommand template to preview inside the empty prompt @@ -896,6 +903,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [setupDecision, setSetupDecision] = useState<'run' | 'skip' | null>(null) const [creating, setCreating] = useState(false) const [createError, setCreateError] = useState(null) + // Why: when checked, a successful worktree create keeps the modal open and + // resets identity fields so the user can queue another worktree without + // reopening. Defaults off; the modal unmounts on close, so reopening always + // starts unchecked. + const [createMultiple, setCreateMultiple] = useState(false) const [advancedOpen, setAdvancedOpen] = useState( persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false ) @@ -3229,6 +3241,37 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS submitFolderTarget ]) + const resetForNextCreate = useCallback(() => { + // Why: with "create multiple" on, clear identity fields after each create so + // the next worktree starts clean. Context (repo, base branch, agent, project + // group) is intentionally retained for fast sequential creation. The + // PR-pick-derived refs (compare base, push target, branch override) are + // identity, not durable context, so they reset too — leaving them while the + // linked item is cleared would carry a half-set Start-from state (e.g. a + // silent fork push target) into the next worktree. + setName('') + lastAutoNameRef.current = '' + setAgentPrompt('') + setNote('') + setAttachmentPaths([]) + setLinkedWorkItem(null) + setLinkedIssue('') + setLinkedPR(null) + setLinkedGitLabIssue(null) + setLinkedGitLabMR(null) + setBranchNameOverride(undefined) + setBranchNameOverridePreservesNameEdits(false) + setCompareBaseRef(undefined) + setPushTarget(undefined) + setReuseSelectedBranch(false) + setStartFromResetHint(null) + setForkPushWarning(null) + setCreateError(null) + // Refocus the name field on the next frame (after the reset re-render) so the + // user can immediately type the next worktree name. + requestAnimationFrame(() => nameInputRef.current?.focus()) + }, []) + const submitQuick = useCallback( async (requestedAgent: TuiAgent | null): Promise => { if (isProjectGroupTarget) { @@ -3525,7 +3568,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS note: trimmedNote, startupPlan, quickPrompt, - quickTelemetry + quickTelemetry, + ...(createMultiple ? { suppressTerminalFocusOnCompletion: true } : {}) } // Why: git fetch + `git worktree add` can take 10–15s; holding the modal @@ -3534,8 +3578,14 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS if (persistDraft) { clearNewWorkspaceDraft() } - onCreated?.() runBackgroundWorktreeCreation(request) + if (createMultiple) { + // Why: keep the modal open and reset identity so the user can queue + // another worktree right away; the creation above runs in the background. + resetForNextCreate() + } else { + onCreated?.() + } } catch (error) { const formattedError = formatWorkspaceCreateError(error) setCreateError(formattedError) @@ -3593,7 +3643,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setupConfig, setupPolicy, isProjectGroupTarget, - submitFolderTarget + submitFolderTarget, + createMultiple, + resetForNextCreate ] ) @@ -3643,6 +3695,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS smartNameSelection?.kind === 'branch', reuseSelectedBranch, onReuseSelectedBranchChange: handleReuseSelectedBranchChange, + // Why: the "create multiple" toggle only applies to worktree (git) targets; + // folder-workspace targets keep the create-and-close behavior. + showCreateMultiple: !isProjectGroupTarget, + createMultiple, + onCreateMultipleChange: setCreateMultiple, agentPrompt, onAgentPromptChange: setAgentPrompt, linkedOnlyTemplatePreview: shouldApplyLinkedOnlyTemplate ? linkedOnlyTemplatePrompt : null, diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts index e37a5705fec..a30b3f97ce5 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.listener-deps.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' function createReactHookHarness() { const refs: { current: unknown }[] = [] - const effects: { deps: readonly unknown[] | undefined }[] = [] + const effects: { + deps: readonly unknown[] | undefined + effect: () => void | (() => void) + }[] = [] let refIndex = 0 return { @@ -13,8 +16,8 @@ function createReactHookHarness() { effects, react: { useCallback: unknown>(callback: T): T => callback, - useLayoutEffect: (_effect: () => void | (() => void), deps?: readonly unknown[]) => { - effects.push({ deps }) + useLayoutEffect: (effect: () => void | (() => void), deps?: readonly unknown[]) => { + effects.push({ deps, effect }) }, useMemo: (factory: () => T): T => factory(), useRef: (initialValue: T): { current: T } => { @@ -69,4 +72,99 @@ describe('useVirtualizedScrollAnchor listener effect dependencies', () => { expect(initialDeps).toEqual([scrollElementRef, scrollOffsetRef]) expect(nextDeps).toEqual(initialDeps) }) + + it('keeps the target anchor while measured fallback restores a transitional window', async () => { + const harness = createReactHookHarness() + vi.doMock('react', () => harness.react) + const { useVirtualizedScrollAnchor } = await import('./useVirtualizedScrollAnchor') + + const anchorRef = { current: { key: 'row-1', offset: 3358 } } + const scrollElementRef = { + current: { + clientHeight: 880, + scrollHeight: 30_000, + scrollTop: 0, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + } + const scrollOffsetRef = { current: 0 } + const virtualizer = { + getVirtualItems: () => [ + { index: 8, start: 0, end: 30_000 }, + { index: 1, start: 758, end: 4512 } + ], + isScrolling: false, + scrollToIndex: vi.fn() + } + + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getRowKey: (row) => row, + rows: Array.from({ length: 9 }, (_, index) => `row-${index}`), + scrollElementRef, + scrollOffsetRef, + totalSize: 30_000, + virtualizer + } as never) + + harness.effects[1]?.effect() + + expect(scrollElementRef.current.scrollTop).toBe(4116) + expect(anchorRef.current).toEqual({ key: 'row-1', offset: 3358 }) + }) + + it('can ignore generic scroll anchor recording while preserving the saved anchor', async () => { + const harness = createReactHookHarness() + vi.doMock('react', () => harness.react) + const { useVirtualizedScrollAnchor } = await import('./useVirtualizedScrollAnchor') + + const capturedScrollHandler: { current: (() => void) | null } = { current: null } + const savedAnchor = { key: 'row-1', offset: 3358 } + const anchorRef = { current: savedAnchor } + const scrollElementRef = { + current: { + clientHeight: 880, + scrollHeight: 30_000, + scrollTop: 746, + addEventListener: vi.fn((eventName: string, handler: () => void) => { + if (eventName === 'scroll') { + capturedScrollHandler.current = handler + } + }), + removeEventListener: vi.fn() + } + } + const scrollOffsetRef = { current: 0 } + const virtualizer = { + getVirtualItems: () => [ + { index: 0, start: 0, end: 3_000 }, + { index: 1, start: 3_000, end: 7_000 } + ], + isScrolling: false, + scrollToIndex: vi.fn() + } + + harness.beginRender() + // oxlint-disable-next-line react-hooks/rules-of-hooks -- test harness mocks React's hook dispatcher directly. + useVirtualizedScrollAnchor({ + anchorRef, + getRowKey: (row) => row, + recordAnchorOnScroll: false, + rows: ['row-0', 'row-1'], + scrollElementRef, + scrollOffsetRef, + totalSize: 30_000, + virtualizer + } as never) + + harness.effects[0]?.effect() + expect(capturedScrollHandler.current).not.toBeNull() + capturedScrollHandler.current?.() + + expect(scrollOffsetRef.current).toBe(0) + expect(anchorRef.current).toBe(savedAnchor) + }) }) diff --git a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts index d94ef02199f..7dd9764e7f6 100644 --- a/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts +++ b/src/renderer/src/hooks/useVirtualizedScrollAnchor.ts @@ -8,6 +8,10 @@ import { } from 'react' import type { Virtualizer } from '@tanstack/react-virtual' import { shouldCancelVirtualizedScrollOffsetRestore } from './virtualizedScrollOffsetRestore' +import { + findVirtualizedDomScrollAnchor, + getVirtualizedScrollAnchorForOffset +} from './virtualized-scroll-anchor-recording' export type VirtualizedScrollAnchor = { fallbackKeys?: readonly string[] @@ -27,6 +31,10 @@ type UseVirtualizedScrollAnchorOptions< getRowKey: (row: TRow) => string hasDirectScrollInput?: () => boolean itemElementSelector?: string + recordAnchorOnCleanup?: boolean + // Why: some callers record user scroll anchors outside this hook; passive + // programmatic scroll events during remount must not teach them a transient row. + recordAnchorOnScroll?: boolean rows: readonly TRow[] scrollElementRef: RefObject scrollOffsetRef: MutableRefObject @@ -53,6 +61,8 @@ export function useVirtualizedScrollAnchor< getRowKey, hasDirectScrollInput, itemElementSelector, + recordAnchorOnCleanup = true, + recordAnchorOnScroll = true, rows, scrollElementRef, scrollOffsetRef, @@ -68,64 +78,14 @@ export function useVirtualizedScrollAnchor< return indexByKey }, [getRowKey, rows]) - const findDomAnchor = useCallback( - (scrollElement: TScrollElement) => { - if (!itemElementSelector || !getItemElementKey) { - return null - } - const scrollRect = scrollElement.getBoundingClientRect() - type DomAnchorItem = { element: TItemElement; key: string; rect: DOMRect } - const visibleItems = Array.from( - scrollElement.querySelectorAll(itemElementSelector) - ) - .map((element) => { - const key = getItemElementKey(element) - if (!key || !rowIndexByKey.has(key) || !element.isConnected) { - return null - } - const rect = element.getBoundingClientRect() - if (rect.height <= 0 || rect.bottom <= scrollRect.top || rect.top >= scrollRect.bottom) { - return null - } - return { element, key, rect } - }) - .filter((item): item is DomAnchorItem => item != null) - .sort((a, b) => a.rect.top - b.rect.top) - - const [firstVisible] = visibleItems - if (!firstVisible) { - return null - } - return { - fallbackKeys: visibleItems.slice(1).map((item) => item.key), - key: firstVisible.key, - offset: Math.min( - firstVisible.rect.height, - Math.max(0, scrollRect.top - firstVisible.rect.top) - ) - } - }, - [getItemElementKey, itemElementSelector, rowIndexByKey] - ) - const recordVirtualScrollAnchor = useCallback( (scrollTop: number) => { - const virtualItems = virtualizer.getVirtualItems() - const firstVisible = virtualItems.find((item) => item.end > scrollTop) - const row = firstVisible ? rows[firstVisible.index] : undefined - if (!firstVisible || !row) { - anchorRef.current = null - return - } - anchorRef.current = { - fallbackKeys: virtualItems - .slice(virtualItems.indexOf(firstVisible) + 1) - .map((item) => rows[item.index]) - .filter((row): row is TRow => row != null) - .map(getRowKey), - key: getRowKey(row), - offset: Math.max(0, scrollTop - firstVisible.start) - } + anchorRef.current = getVirtualizedScrollAnchorForOffset({ + getRowKey, + rows, + scrollTop, + virtualItems: virtualizer.getVirtualItems() + }) }, [anchorRef, getRowKey, rows, virtualizer] ) @@ -133,17 +93,36 @@ export function useVirtualizedScrollAnchor< const recordScrollAnchor = useCallback( (scrollTop: number) => { const scrollElement = scrollElementRef.current - if (scrollElement) { - const domAnchor = findDomAnchor(scrollElement) + if (scrollElement && itemElementSelector && getItemElementKey) { + const domAnchor = findVirtualizedDomScrollAnchor({ + getItemElementKey, + itemElementSelector, + rowIndexByKey, + scrollElement + }) if (domAnchor) { anchorRef.current = domAnchor return } } - recordVirtualScrollAnchor(scrollTop) + anchorRef.current = getVirtualizedScrollAnchorForOffset({ + getRowKey, + rows, + scrollTop, + virtualItems: virtualizer.getVirtualItems() + }) }, - [anchorRef, findDomAnchor, recordVirtualScrollAnchor, scrollElementRef] + [ + anchorRef, + getItemElementKey, + getRowKey, + itemElementSelector, + rowIndexByKey, + rows, + scrollElementRef, + virtualizer + ] ) // Why: row changes must not re-register the scroll listener; cleanup records @@ -154,6 +133,10 @@ export function useVirtualizedScrollAnchor< recordVirtualScrollAnchorRef.current = recordVirtualScrollAnchor const hasDirectScrollInputRef = useRef(hasDirectScrollInput) hasDirectScrollInputRef.current = hasDirectScrollInput + const recordAnchorOnCleanupRef = useRef(recordAnchorOnCleanup) + recordAnchorOnCleanupRef.current = recordAnchorOnCleanup + const recordAnchorOnScrollRef = useRef(recordAnchorOnScroll) + recordAnchorOnScrollRef.current = recordAnchorOnScroll useLayoutEffect(() => { const el = scrollElementRef.current @@ -216,18 +199,29 @@ export function useVirtualizedScrollAnchor< // user's real position until the intended offset is reachable. if (el.scrollTop === targetOffset) { restoring = false - recordCurrentAnchor() + if (recordAnchorOnScrollRef.current) { + recordCurrentAnchor() + } else { + scrollOffsetRef.current = el.scrollTop + } return } if (el.scrollHeight - el.clientHeight >= targetOffset) { el.scrollTop = targetOffset if (el.scrollTop === targetOffset) { restoring = false - recordCurrentAnchor() + if (recordAnchorOnScrollRef.current) { + recordCurrentAnchor() + } else { + scrollOffsetRef.current = el.scrollTop + } } } return } + if (!recordAnchorOnScrollRef.current) { + return + } scrollOffsetRef.current = el.scrollTop recordVirtualScrollAnchorRef.current(el.scrollTop) scheduleRecordAnchor() @@ -237,8 +231,10 @@ export function useVirtualizedScrollAnchor< el.addEventListener(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, recordCurrentAnchor) return () => { cancelScheduledRecord() - scrollOffsetRef.current = el.scrollTop - recordScrollAnchorRef.current(el.scrollTop) + if (recordAnchorOnCleanupRef.current) { + scrollOffsetRef.current = el.scrollTop + recordScrollAnchorRef.current(el.scrollTop) + } el.removeEventListener(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, recordCurrentAnchor) el.removeEventListener('scroll', onScroll) } @@ -250,9 +246,10 @@ export function useVirtualizedScrollAnchor< if (!anchor || !el) { return } - if (virtualizer.isScrolling) { + if (virtualizer.isScrolling && hasDirectScrollInputRef.current?.() === true) { // Why: remeasurement during wheel scrolling can change totalSize. Restoring // the anchor in that window writes scrollTop and fights the user's wheel. + // Programmatic scrolls during remount still need anchor correction. return } if (shouldSkipRestore?.()) { @@ -287,7 +284,11 @@ export function useVirtualizedScrollAnchor< const desiredTop = scrollRect.top - offset const delta = rect.top - desiredTop if (Math.abs(delta) > 1) { + // Why: this scroll write is still part of restore; keep the target + // anchor until a later layout confirms the intended row is in place. el.scrollTop += delta + scrollOffsetRef.current = el.scrollTop + return true } scrollOffsetRef.current = el.scrollTop recordScrollAnchor(el.scrollTop) @@ -304,8 +305,9 @@ export function useVirtualizedScrollAnchor< if (Math.abs(el.scrollTop - nextScrollTop) > 1) { el.scrollTop = nextScrollTop } + // Why: measured fallback can run while TanStack's virtual window is + // transitional, so recording here can replace the target with a wrong row. scrollOffsetRef.current = el.scrollTop - recordScrollAnchor(el.scrollTop) return true } diff --git a/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts b/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts new file mode 100644 index 00000000000..bf77d2426ff --- /dev/null +++ b/src/renderer/src/hooks/virtualized-scroll-anchor-recording.ts @@ -0,0 +1,75 @@ +import type { VirtualizedScrollAnchor } from './useVirtualizedScrollAnchor' + +type VirtualScrollItem = { + end: number + index: number + start: number +} + +export function findVirtualizedDomScrollAnchor({ + getItemElementKey, + itemElementSelector, + rowIndexByKey, + scrollElement +}: { + getItemElementKey: (element: TItemElement) => string | null + itemElementSelector: string + rowIndexByKey: ReadonlyMap + scrollElement: Element +}): NonNullable | null { + const scrollRect = scrollElement.getBoundingClientRect() + type DomAnchorItem = { key: string; rect: DOMRect } + const visibleItems = Array.from(scrollElement.querySelectorAll(itemElementSelector)) + .map((element) => { + const key = getItemElementKey(element) + if (!key || !rowIndexByKey.has(key) || !element.isConnected) { + return null + } + const rect = element.getBoundingClientRect() + if (rect.height <= 0 || rect.bottom <= scrollRect.top || rect.top >= scrollRect.bottom) { + return null + } + return { key, rect } + }) + .filter((item): item is DomAnchorItem => item != null) + .sort((a, b) => a.rect.top - b.rect.top) + + const [firstVisible] = visibleItems + if (!firstVisible) { + return null + } + + return { + fallbackKeys: visibleItems.slice(1).map((item) => item.key), + key: firstVisible.key, + offset: Math.min(firstVisible.rect.height, Math.max(0, scrollRect.top - firstVisible.rect.top)) + } +} + +export function getVirtualizedScrollAnchorForOffset({ + getRowKey, + rows, + scrollTop, + virtualItems +}: { + getRowKey: (row: TRow) => string + rows: readonly TRow[] + scrollTop: number + virtualItems: readonly VirtualScrollItem[] +}): VirtualizedScrollAnchor { + const firstVisible = virtualItems.find((item) => item.end > scrollTop) + const row = firstVisible ? rows[firstVisible.index] : undefined + if (!firstVisible || !row) { + return null + } + + return { + fallbackKeys: virtualItems + .slice(virtualItems.indexOf(firstVisible) + 1) + .map((item) => rows[item.index]) + .filter((row): row is TRow => row != null) + .map(getRowKey), + key: getRowKey(row), + offset: Math.max(0, scrollTop - firstVisible.start) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 86a665eafb7..c05c5058070 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1049,6 +1049,7 @@ "NewWorkspaceComposerCard": { "reuseExistingBranch": "Reuse branch", "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", + "createMultiple": "Create more", "cbb47ee0dc": "Only available for local Git projects.", "d861de981b": "Sparse checkout", "090cfedeb4": "Write a note", @@ -1272,7 +1273,10 @@ "2b4fdb880c": "Unmark viewed", "56ec6eafb7": "Open the PR details to view current reviewers.", "7f964a365a": "Remove reviewer", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "Move", @@ -1602,7 +1606,25 @@ "fe28c9821f": "view", "8d1e17a3ef": "Open {{value0}} in GitHub", "4ac8ff2275": "Open {{value0}} in Jira", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "Close", @@ -1703,7 +1725,10 @@ "settingsBadge": "Settings", "actionBadge": "Action", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "Tab no longer exists" + "workspaceTabMissing": "Tab no longer exists", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3359,22 +3384,17 @@ "496f68cf8c": "Scanning repositories. Click to stop.", "a32bef9516": "Stop scanning", "2f8298f3c3": "Stop scan", - "c157f31a95": "Import as group", - "40199ef7b3": "Group name", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "787412361a": "What is a group name?", "5f857ba8e6": "in", "4df0d08cc5": "Found", "8db50afe1a": "Import repositories from folder", "5b2e6fe3c8": "Import separately", "cf9d382ca1": "Import", "220dd32d83": "Scanning...", - "fb33359f69": "Is this a monorepo?", - "d75170194e": "Choose this if these projects belong together. Orca will group them and let you work from the parent folder.", - "39d51212cc": "Monorepo name", - "e907ec8935": "What is a monorepo name?", + "fb33359f69": "Group these repositories?", + "d75170194e": "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.", + "39d51212cc": "Group name", "aa0247680d": "No, import separately", - "a0bc4d1f8e": "Yes, import as monorepo", + "a0bc4d1f8e": "Yes, import as group", "8401a7a0d0": "1 repository", "d4f1df62ef": "{{value0}} repositories", "b4263a2ac4": "Found {{value0}} in {{value1}}.", @@ -3965,7 +3985,8 @@ "f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.", "groupDeleteFailed": "Failed to delete group", "groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.", - "failedNestWorkspace": "Failed to nest workspace" + "failedNestWorkspace": "Failed to nest workspace", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancel", @@ -11060,6 +11081,14 @@ "newQuickCommand": "new quick command" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f4592d35d23..1aad6ed2381 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1102,7 +1102,8 @@ "importHostSetup": "Import", "notePasteTooLarge": "Paste is too large for the note field.", "reuseExistingBranch": "Reuse branch", - "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "Elija el proyecto, el nombre del espacio de trabajo y el agente antes de crear el espacio de trabajo." @@ -1272,7 +1273,10 @@ "2b4fdb880c": "Desmarcar visto", "56ec6eafb7": "Abra los detalles de relaciones públicas para ver los revisores actuales.", "7f964a365a": "Eliminar revisor", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "Mover", @@ -1602,7 +1606,25 @@ "fe28c9821f": "vista", "8d1e17a3ef": "Abra {{value0}} en GitHub", "4ac8ff2275": "Abrir {{value0}} en Jira", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "Cerca", @@ -1703,7 +1725,10 @@ "settingsBadge": "Ajustes", "actionBadge": "Acción", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "La pestaña ya no existe" + "workspaceTabMissing": "La pestaña ya no existe", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3353,26 +3378,21 @@ "496f68cf8c": "Escaneo de repositorios. Haga clic para detener.", "a32bef9516": "Dejar de escanear", "2f8298f3c3": "Detener escaneo", - "c157f31a95": "Importar como grupo", - "40199ef7b3": "Nombre del grupo", - "787412361a": "¿Qué es el nombre de un grupo?", "5f857ba8e6": "en", "4df0d08cc5": "Encontró", "8db50afe1a": "Importar repositorios desde la carpeta", "5b2e6fe3c8": "Importar por separado", "cf9d382ca1": "Importar", "220dd32d83": "Exploración...", - "fb33359f69": "¿Es esto un monorepo?", - "d75170194e": "Impórtalos como grupo si son un monorepo o si pertenecen juntos. Orca los agrupará y te permitirá trabajar desde la carpeta principal.", + "fb33359f69": "¿Agrupar estos repositorios?", + "d75170194e": "Elige esto si estos proyectos van juntos: un monorepo o simplemente un conjunto de repositorios relacionados. Orca los agrupará y te permitirá trabajar desde la carpeta principal.", "39d51212cc": "Nombre del grupo", "aa0247680d": "No, importar por separado", - "a0bc4d1f8e": "Importar como grupo", + "a0bc4d1f8e": "Sí, importar como grupo", "8401a7a0d0": "1 repositorio", "d4f1df62ef": "{{value0}} repositorios", "b4263a2ac4": "Se encontraron {{value0}} en {{value1}}.", - "24eda6c8b2": "Explorando... {{value0}}", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "e907ec8935": "What is a monorepo name?" + "24eda6c8b2": "Explorando... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "Detener escaneo", @@ -3951,7 +3971,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "No se pudo anidar el espacio de trabajo" + "failedNestWorkspace": "No se pudo anidar el espacio de trabajo", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "Cancelar", @@ -11060,6 +11081,14 @@ "newQuickCommand": "nuevo comando rápido" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 205a953ec87..3e2ac5ce512 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1102,7 +1102,8 @@ "importHostSetup": "インポート", "notePasteTooLarge": "Paste is too large for the note field.", "reuseExistingBranch": "Reuse branch", - "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "ワークスペースを作成する前に、プロジェクト、ワークスペース名、および agent を選択します。" @@ -1272,7 +1273,10 @@ "2b4fdb880c": "閲覧済みのマークを外す", "56ec6eafb7": "PR の詳細を開いて、現在のレビュアーを表示します。", "7f964a365a": "レビュアーを削除", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "移動", @@ -1602,7 +1606,25 @@ "fe28c9821f": "view", "8d1e17a3ef": "{{value0}} を GitHub で開く", "4ac8ff2275": "{{value0}} を Jira で開く", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "閉じる", @@ -1703,7 +1725,10 @@ "settingsBadge": "設定", "actionBadge": "操作", "paletteHostBadge": "Host: {{value0}}", - "workspaceTabMissing": "タブはもう存在しません" + "workspaceTabMissing": "タブはもう存在しません", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3334,26 +3359,21 @@ "496f68cf8c": "repos をスキャンしています。クリックして停止します。", "a32bef9516": "スキャンを停止する", "2f8298f3c3": "スキャンの停止", - "c157f31a95": "グループとしてインポート", - "40199ef7b3": "グループ名", - "787412361a": "グループ名とは何ですか?", "5f857ba8e6": "で", "4df0d08cc5": "見つかった", "8db50afe1a": "フォルダーから repos をインポートする", "5b2e6fe3c8": "個別にインポート", "cf9d382ca1": "インポート", "220dd32d83": "スキャン中...", - "fb33359f69": "これはモノレポですか?", - "d75170194e": "モノレポ、または一緒に扱うべきプロジェクトの場合は、グループとしてインポートします。Orca がグループ化し、親フォルダーから作業できるようにします。", + "fb33359f69": "これらのリポジトリをグループ化しますか?", + "d75170194e": "これらのプロジェクトをまとめたい場合(モノレポでも、関連するリポジトリの集まりでも)に選択します。Orca がグループ化し、親フォルダーから作業できるようにします。", "39d51212cc": "グループ名", "aa0247680d": "いいえ、個別にインポート", - "a0bc4d1f8e": "グループとしてインポート", + "a0bc4d1f8e": "はい、グループとしてインポート", "8401a7a0d0": "1 個の repos", "d4f1df62ef": "{{value0}} 個の repos", "b4263a2ac4": "{{value1}} で {{value0}} が見つかりました。", - "24eda6c8b2": "スキャン中... {{value0}}", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "e907ec8935": "What is a monorepo name?" + "24eda6c8b2": "スキャン中... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "スキャンの停止", @@ -3932,7 +3952,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "ワークスペースをネストできませんでした" + "failedNestWorkspace": "ワークスペースをネストできませんでした", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "キャンセル", @@ -11060,6 +11081,14 @@ "newQuickCommand": "新規クイックコマンド" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 60f7ac0ebbb..d452e81de2c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1102,7 +1102,8 @@ "importHostSetup": "가져오기", "notePasteTooLarge": "메모 필드에 붙여넣을 내용이 너무 큽니다.", "reuseExistingBranch": "Reuse branch", - "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it." + "reuseExistingBranchHint": "Check out the existing branch instead of creating a new one from it.", + "createMultiple": "Create more" }, "NewWorkspaceComposerModal": { "fa90f739a5": "워크스페이스를 만들기 전에 프로젝트, 워크스페이스 이름, agent를 선택하세요." @@ -1272,7 +1273,10 @@ "2b4fdb880c": "본 표시 해제", "56ec6eafb7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.", "7f964a365a": "리뷰어 삭제", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "이동", @@ -1602,7 +1606,25 @@ "fe28c9821f": "보기", "8d1e17a3ef": "GitHub에서 {{value0}} 열기", "4ac8ff2275": "Jira에서 {{value0}} 열기", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "닫기", @@ -1703,7 +1725,10 @@ "settingsBadge": "설정", "actionBadge": "작업", "paletteHostBadge": "호스트: {{value0}}", - "workspaceTabMissing": "탭이 더 이상 존재하지 않습니다" + "workspaceTabMissing": "탭이 더 이상 존재하지 않습니다", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3334,26 +3359,21 @@ "496f68cf8c": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.", "a32bef9516": "스캔 중지", "2f8298f3c3": "스캔 중지", - "c157f31a95": "그룹으로 가져오기", - "40199ef7b3": "그룹 이름", - "787412361a": "그룹 이름은 무엇입니까?", "5f857ba8e6": "~에", "4df0d08cc5": "발견됨", "8db50afe1a": "폴더에서 repos 가져오기", "5b2e6fe3c8": "별도로 가져오기", "cf9d382ca1": "가져오기", "220dd32d83": "스캔 중...", - "fb33359f69": "이 폴더가 모노 repo 인가요?", - "d75170194e": "모노레포이거나 함께 속한 프로젝트라면 그룹으로 가져오세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.", + "fb33359f69": "이 저장소들을 그룹으로 묶을까요?", + "d75170194e": "이 프로젝트들이 함께 속한 경우(모노레포이거나 단순히 관련된 저장소 모음) 이 옵션을 선택하세요. Orca가 그룹으로 묶고 상위 폴더에서 작업할 수 있게 합니다.", "39d51212cc": "그룹 이름", "aa0247680d": "아니요, 별도로 가져오기", - "a0bc4d1f8e": "그룹으로 가져오기", + "a0bc4d1f8e": "예, 그룹으로 가져오기", "8401a7a0d0": "repos 1개", "d4f1df62ef": "repos {{value0}}개", "b4263a2ac4": "{{value1}}에서 {{value0}}을(를) 찾았습니다.", - "24eda6c8b2": "스캔 중... {{value0}}", - "b20bb7c24f": "이 리포지토리들을 하나의 그룹으로 묶어 둡니다. 마이크로서비스처럼 서로 관련된 리포지토리에 적합합니다.", - "e907ec8935": "모노 repo 이름이란?" + "24eda6c8b2": "스캔 중... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "스캔 중지", @@ -3932,7 +3952,8 @@ "7a8b9c0d1e": "업데이트 필요", "hostAuthNeeded": "인증 필요", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다" + "failedNestWorkspace": "워크스페이스를 중첩하지 못했습니다", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "취소", @@ -11060,6 +11081,14 @@ "newQuickCommand": "새로운 빠른 명령" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0c5d2d3c9fd..0c7753af883 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1272,7 +1272,10 @@ "2b4fdb880c": "取消标记已查看", "56ec6eafb7": "打开 PR 详情以查看当前评审人。", "7f964a365a": "移除评审人", - "commentTooLarge": "评论过长,无法安全提交。" + "commentTooLarge": "评论过长,无法安全提交。", + "8ff5ae8866": "Assignees", + "82c87eceb9": "Edit assignees", + "1ff5d979df": "No one assigned" }, "QuickOpen": { "1dbd3f59ff": "手机", @@ -1602,7 +1605,25 @@ "fe28c9821f": "视图", "8d1e17a3ef": "在 GitHub 中打开 {{value0}}", "4ac8ff2275": "在 Jira 中打开 {{value0}}", - "40eaf2c27c": "Details" + "40eaf2c27c": "Details", + "closeAsCompleted": "Close as completed", + "closeAsCompletedDescription": "Done, closed, fixed, resolved", + "closeAsNotPlanned": "Close as not planned", + "closeAsNotPlannedDescription": "Won't fix, can't repro, stale", + "closeAsDuplicate": "Close as duplicate", + "closeAsDuplicateDescription": "Duplicate of another issue in this repository", + "duplicateIssueNumberPlaceholder": "Issue number", + "closeAsDuplicateSubmit": "Close duplicate", + "duplicateIssueMissing": "Enter an issue number in this repository.", + "duplicateIssueNotInteger": "Use a whole issue number.", + "duplicateIssueNotPositive": "Use a positive issue number.", + "duplicateIssueSameIssue": "Choose a different issue.", + "repository": "Repository", + "backToCloseReasons": "Back", + "searchIssues": "Search issues", + "useIssueNumber": "Use issue #{{value0}}", + "noMatchingIssuesLoaded": "No matching issues loaded.", + "editReviewersWithCurrent": "Edit reviewers: {{value0}}" }, "Terminal": { "73768427cf": "关闭", @@ -1703,7 +1724,10 @@ "settingsBadge": "设置", "actionBadge": "操作", "paletteHostBadge": "主机:{{value0}}", - "workspaceTabMissing": "标签页不再存在" + "workspaceTabMissing": "标签页不再存在", + "projectsGroupsHeader": "Projects & Groups", + "projectBadge": "Project", + "repoGroupBadge": "Repo group" }, "github": { "pr": { @@ -3334,26 +3358,21 @@ "496f68cf8c": "扫描存储库。单击停止。", "a32bef9516": "停止扫描", "2f8298f3c3": "停止扫描", - "c157f31a95": "作为组导入", - "40199ef7b3": "团体名称", - "787412361a": "什么是群组名称?", "5f857ba8e6": "在", "4df0d08cc5": "成立", "8db50afe1a": "从文件夹导入存储库", "5b2e6fe3c8": "单独导入", "cf9d382ca1": "导入", "220dd32d83": "扫描...", - "fb33359f69": "这是 Monorepo 吗?", - "d75170194e": "如果它们是 Monorepo,或本来就应该放在一起,请将它们作为组导入。Orca 会将它们分组,并让你从父文件夹开始工作。", + "fb33359f69": "将这些仓库分组吗?", + "d75170194e": "如果这些项目应放在一起(可以是 Monorepo,也可以只是一组相关仓库),请选择此项。Orca 会将它们分组,并让你从父文件夹开始工作。", "39d51212cc": "组名称", "aa0247680d": "否,单独导入", - "a0bc4d1f8e": "作为组导入", + "a0bc4d1f8e": "是,作为组导入", "8401a7a0d0": "1 个仓库", "d4f1df62ef": "{{value0}} 个仓库", "b4263a2ac4": "在 {{value1}} 中找到 {{value0}}。", - "24eda6c8b2": "正在扫描... {{value0}}", - "b20bb7c24f": "Keeps these repos together in one group. Best for related repos like microservices.", - "e907ec8935": "What is a monorepo name?" + "24eda6c8b2": "正在扫描... {{value0}}" }, "AddRepoRemoteStep": { "5b205b5281": "停止扫描", @@ -3932,7 +3951,8 @@ "7a8b9c0d1e": "Update required", "hostAuthNeeded": "Authentication needed", "hostDisconnected": "Disconnected", - "failedNestWorkspace": "无法嵌套工作区" + "failedNestWorkspace": "无法嵌套工作区", + "sidebarRowMissing": "Target no longer exists" }, "WorktreeMetaDialog": { "3db0a2a593": "取消", @@ -11060,6 +11080,14 @@ "newQuickCommand": "新的快速命令" } } + }, + "palette": { + "project": { + "results": { + "repoGroup": "Repo group", + "project": "Project" + } + } } } }, diff --git a/src/renderer/src/lib/lazy-with-retry.test.ts b/src/renderer/src/lib/lazy-with-retry.test.ts index 7ef2f26fdec..a783a28e3a2 100644 --- a/src/renderer/src/lib/lazy-with-retry.test.ts +++ b/src/renderer/src/lib/lazy-with-retry.test.ts @@ -2,11 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ComponentType } from 'react' -import { loadLazyWithRetry } from './lazy-with-retry' +import { isLazyChunkLoadError, loadLazyWithRetry } from './lazy-with-retry' const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' const Comp: ComponentType = () => null const chunkParseError = (): SyntaxError => new SyntaxError("Unexpected token ']'") +const chunkFetchError = (): TypeError => + new TypeError('Failed to fetch dynamically imported module: file://redacted/chunk.js') function spyOnReload(): ReturnType { const reload = vi.fn() @@ -107,21 +109,58 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe(false) }) - it('does NOT reload twice — re-throws once the guard is already set', async () => { + it('does NOT reload twice — wraps known chunk failures once the guard is already set', async () => { const reload = spyOnReload() window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') - const error = chunkParseError() + const error = chunkFetchError() const factory = vi.fn(() => Promise.reject(error)) const loaded = loadLazyWithRetry(factory, { retries: 2, baseDelayMs: 250 }) + const assertion = expect(loaded).rejects.toMatchObject({ + name: 'LazyChunkLoadError', + cause: error + }) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(true) + }) + + it('preserves the original error when the guarded failure is not a dynamic import failure', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = new Error('render bug from lazy module evaluation') + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) const assertion = expect(loaded).rejects.toBe(error) await vi.advanceTimersByTimeAsync(5000) await assertion expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) - it('fails closed (re-throws, never reloads) when sessionStorage reads throw', async () => { + it('preserves bare parse errors so lazy module evaluation bugs still report', async () => { + const reload = spyOnReload() + window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + const error = chunkParseError() + const factory = vi.fn(() => Promise.reject(error)) + + const loaded = loadLazyWithRetry(factory, { retries: 1, baseDelayMs: 100 }) + const assertion = expect(loaded).rejects.toBe(error) + await vi.advanceTimersByTimeAsync(5000) + await assertion + + expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) + }) + + it('fails closed with the original error when sessionStorage reads throw', async () => { const reload = spyOnReload() // Private-mode / sandboxed storage makes reads throw. The guard must treat // this as "already reloaded" so a broken chunk can NEVER cause a reload loop. @@ -135,6 +174,8 @@ describe('loadLazyWithRetry', () => { await assertion expect(reload).not.toHaveBeenCalled() + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) it('records a lazy_chunk_reload breadcrumb (with reloadKey) before reloading', async () => { @@ -166,7 +207,7 @@ describe('loadLazyWithRetry', () => { expect(settled).toBe(false) }) - it('re-throws without reloading when there is no window (SSR / node)', async () => { + it('re-throws the original error without reloading when there is no window (SSR / node)', async () => { vi.stubGlobal('window', undefined) const error = chunkParseError() const factory = vi.fn(() => Promise.reject(error)) @@ -177,6 +218,8 @@ describe('loadLazyWithRetry', () => { await assertion expect(factory).toHaveBeenCalledTimes(2) + const caught = await loaded.catch((rejection) => rejection) + expect(isLazyChunkLoadError(caught)).toBe(false) }) it('keeps the reload guard set across a successful load (no second reload in one session)', async () => { diff --git a/src/renderer/src/lib/lazy-with-retry.ts b/src/renderer/src/lib/lazy-with-retry.ts index 5a09f6b2a95..7c3cc604382 100644 --- a/src/renderer/src/lib/lazy-with-retry.ts +++ b/src/renderer/src/lib/lazy-with-retry.ts @@ -18,6 +18,8 @@ type AnyComponent = ComponentType type LazyFactory = () => Promise<{ default: T }> +type ReloadGuardState = 'not-attempted' | 'attempted' | 'unavailable' + export type LazyWithRetryOptions = { retries?: number baseDelayMs?: number @@ -25,6 +27,18 @@ export type LazyWithRetryOptions = { reloadKey?: string } +export class LazyChunkLoadError extends Error { + constructor(cause: unknown) { + super('Lazy chunk load failed after reload recovery was exhausted') + this.name = 'LazyChunkLoadError' + ;(this as { cause?: unknown }).cause = cause + } +} + +export function isLazyChunkLoadError(error: unknown): error is LazyChunkLoadError { + return error instanceof LazyChunkLoadError +} + // One recovery reload per session. The guard survives the reload itself (so we // never loop) but resets when the window/app closes, so a later launch — e.g. // after an update ships fresh chunks — can earn another reload. sessionStorage @@ -35,21 +49,26 @@ const RELOAD_GUARD_KEY = 'orca:lazy-chunk-reload-attempted' const DEFAULT_RETRIES = 2 const DEFAULT_BASE_DELAY_MS = 250 -function hasAttemptedChunkReload(): boolean { +function readChunkReloadGuardState(): ReloadGuardState { + if (typeof window === 'undefined') { + return 'unavailable' + } try { - return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' + return window.sessionStorage.getItem(RELOAD_GUARD_KEY) === '1' ? 'attempted' : 'not-attempted' } catch { - // Why: when sessionStorage is unavailable (private mode / sandboxed), fail - // closed (treat as already-reloaded) so we never risk an infinite reload loop. - return true + // Why: when storage is blocked we cannot prove a reload happened, but still + // fail closed on reloads so a broken chunk never loops. + return 'unavailable' } } -function markChunkReloadAttempted(): void { +function markChunkReloadAttempted(): boolean { try { window.sessionStorage.setItem(RELOAD_GUARD_KEY, '1') + return true } catch { - // Best-effort; if writing throws, hasAttemptedChunkReload() also fails closed. + // A reload without a durable guard can loop, so treat write failure as unavailable. + return false } } @@ -71,6 +90,24 @@ const wait = (ms: number): Promise => new Promise((resolve) => setTimeout( // down, so the error fallback never flashes in the moment before the reload lands. const SUSPEND_UNTIL_RELOAD = new Promise(() => undefined) +function isKnownDynamicImportFailure(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + + if (error.name === 'ChunkLoadError') { + return true + } + + return [ + /failed to fetch dynamically imported module/i, + /error loading dynamically imported module/i, + /importing a module script failed/i, + /failed to load module script/i, + /loading chunk .+ failed/i + ].some((pattern) => pattern.test(error.message)) +} + export async function loadLazyWithRetry( factory: LazyFactory, options: LazyWithRetryOptions = {} @@ -91,8 +128,11 @@ export async function loadLazyWithRetry( } } - if (typeof window !== 'undefined' && !hasAttemptedChunkReload()) { - markChunkReloadAttempted() + const reloadGuardState = readChunkReloadGuardState() + if (typeof window !== 'undefined' && reloadGuardState === 'not-attempted') { + if (!markChunkReloadAttempted()) { + throw lastError + } recordReloadBreadcrumb( options.reloadKey ?? 'unknown', lastError instanceof Error ? lastError.message : String(lastError) @@ -101,8 +141,12 @@ export async function loadLazyWithRetry( return SUSPEND_UNTIL_RELOAD } - // Already reloaded once this session, or no window (SSR / node): re-throw so - // RecoverableRenderErrorBoundary catches and reports it instead of looping. + if (reloadGuardState === 'attempted' && isKnownDynamicImportFailure(lastError)) { + throw new LazyChunkLoadError(lastError) + } + + // No proven reload attempt (SSR / node / blocked storage) or unknown failure: + // re-throw the original error so normal error reporting semantics stay intact. throw lastError } diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 3c83982b53f..6b5d60f004c 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -71,6 +71,9 @@ export type WorktreeCreationRequest = { startupPlan: AgentStartupPlan | null quickPrompt: string quickTelemetry: AgentStartedTelemetry | null + /** When the composer stays open for sequential creates, completion must not + * steal focus from the next workspace name field. */ + suppressTerminalFocusOnCompletion?: boolean } /** Renderer-only, session-ephemeral record of an in-flight (or failed) worktree diff --git a/src/renderer/src/lib/repo-runtime-owner.test.ts b/src/renderer/src/lib/repo-runtime-owner.test.ts index fab3a7547f0..719c964676e 100644 --- a/src/renderer/src/lib/repo-runtime-owner.test.ts +++ b/src/renderer/src/lib/repo-runtime-owner.test.ts @@ -55,6 +55,36 @@ describe('getRuntimeEnvironmentIdForRepo', () => { ).toBe('focused-runtime') }) + it('uses the focused host row when duplicate repo ids exist', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBe('owner-runtime') + }) + + it('does not silently choose a duplicate repo row when the focused host does not match', () => { + expect( + getRuntimeEnvironmentIdForRepo( + { + settings: { activeRuntimeEnvironmentId: 'other-runtime' }, + repos: [ + { id: 'repo-1', connectionId: null, executionHostId: 'local' }, + { id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' } + ] + }, + 'repo-1' + ) + ).toBe('other-runtime') + }) + it('returns settings scoped to an explicit local repo owner', () => { expect( getSettingsForRepoRuntimeOwner( diff --git a/src/renderer/src/lib/repo-runtime-owner.ts b/src/renderer/src/lib/repo-runtime-owner.ts index 81905e9d1b3..36b7cf757d2 100644 --- a/src/renderer/src/lib/repo-runtime-owner.ts +++ b/src/renderer/src/lib/repo-runtime-owner.ts @@ -1,4 +1,8 @@ -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + parseExecutionHostId +} from '../../../shared/execution-host' import type { GlobalSettings, Repo } from '../../../shared/types' export type RepoRuntimeOwnerState = { @@ -6,6 +10,26 @@ export type RepoRuntimeOwnerState = { settings?: Pick | null } +function findRepoOwner( + state: RepoRuntimeOwnerState, + repoId: string +): Pick | null { + const matchingRepos = state.repos?.filter((entry) => entry.id === repoId) ?? [] + if (matchingRepos.length === 0) { + return null + } + if (matchingRepos.length === 1) { + return matchingRepos[0] + } + const focusedHostId = getSettingsFocusedExecutionHostId(state.settings) + const focusedMatches = matchingRepos.filter( + (entry) => getRepoExecutionHostId(entry) === focusedHostId + ) + // Why: duplicate bare repo ids are only safe to route when focus selects one + // owner unambiguously; otherwise callers must avoid guessing a host. + return focusedMatches.length === 1 ? focusedMatches[0] : null +} + export function getRuntimeEnvironmentIdForRepo( state: RepoRuntimeOwnerState, repoId: string | null | undefined @@ -13,7 +37,7 @@ export function getRuntimeEnvironmentIdForRepo( if (!repoId) { return null } - const repo = state.repos?.find((entry) => entry.id === repoId) + const repo = findRepoOwner(state, repoId) const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim()) if (repo && hasExplicitOwner) { const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) diff --git a/src/renderer/src/lib/scroll-to-current-workspace-status.ts b/src/renderer/src/lib/scroll-to-current-workspace-status.ts index fbca39dc2a1..6f077da3b06 100644 --- a/src/renderer/src/lib/scroll-to-current-workspace-status.ts +++ b/src/renderer/src/lib/scroll-to-current-workspace-status.ts @@ -1,9 +1,15 @@ export const SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT = 'orca-scroll-to-current-workspace-reveal-request' -export type ScrollToCurrentWorkspaceRevealRequestDetail = { - beginRename?: boolean -} +export type ScrollToCurrentWorkspaceRevealRequestDetail = + | { + target?: { type: 'active-workspace' } + beginRename?: boolean + } + | { + target: { type: 'sidebar-row'; rowKey: string } + highlight?: boolean + } function dispatchScrollToCurrentWorkspaceReveal( detail?: ScrollToCurrentWorkspaceRevealRequestDetail @@ -21,5 +27,8 @@ export function requestScrollToCurrentWorkspaceReveal(): void { } export function requestScrollToCurrentWorkspaceRevealAndRename(): void { - dispatchScrollToCurrentWorkspaceReveal({ beginRename: true }) + dispatchScrollToCurrentWorkspaceReveal({ + target: { type: 'active-workspace' }, + beginRename: true + }) } diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 576a2a341ad..872455495dc 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -196,7 +196,7 @@ async function executeWorktreeCreation( startup: request.startupPlan }) } - if (stillActive) { + if (stillActive && !request.suppressTerminalFocusOnCompletion) { queueNewWorkspaceTerminalFocus(worktree.id, activation) } diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 8dabf867757..d4f1f57abc4 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -132,6 +132,37 @@ describe('runtime git client', () => { }) }) + it('forwards upstream-negative-cache bypass to local git status only when enabled', async () => { + gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: true } + ) + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: false } + ) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined + }) + }) + it('checks ignored paths through local git IPC', async () => { gitCheckIgnored.mockResolvedValue(['dist/bundle.js']) @@ -262,6 +293,31 @@ describe('runtime git client', () => { }) }) + it('forwards upstream-negative-cache bypass through the active runtime environment', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { entries: [], conflictOperation: 'unknown' }, + _meta: { runtimeId: 'remote-runtime' } + }) + + await getRuntimeGitStatus( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { bypassEffectiveUpstreamNegativeCache: true } + ) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'git.status', + params: { worktree: 'id:wt-1', bypassEffectiveUpstreamNegativeCache: true }, + timeoutMs: 15_000 + }) + }) + it('checks ignored paths through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index f7c9c02f06d..b0795718ac2 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -121,21 +121,29 @@ export function getRuntimeGitScope( export async function getRuntimeGitStatus( context: RuntimeGitContext, - options?: { includeIgnored?: boolean } + options?: { includeIgnored?: boolean; bypassEffectiveUpstreamNegativeCache?: boolean } ): Promise { const target = getActiveRuntimeTarget(context.settings) const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} + const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {} if (target.kind === 'local' || !context.worktreeId) { return window.api.git.status({ worktreePath: context.worktreePath, connectionId: context.connectionId, - ...includeIgnoredArgs + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs }) } return callRuntimeRpc( target, 'git.status', - { worktree: toRuntimeWorktreeSelector(context.worktreeId), ...includeIgnoredArgs }, + { + worktree: toRuntimeWorktreeSelector(context.worktreeId), + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs + }, { timeoutMs: 15_000 } ) } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 3780be8c6e3..213f575e25e 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -295,6 +295,7 @@ type EditorOpenTargetOptions = { type GitRuntimeOperationOptions = { runtimeTargetSettings?: Pick | null + applyUpstreamStatus?: boolean } export type PendingEditorReveal = { @@ -596,7 +597,7 @@ export type EditorSlice = { connectionId?: string, pushTarget?: GitPushTarget, options?: GitRuntimeOperationOptions - ) => Promise + ) => Promise pushBranch: ( worktreeId: string, worktreePath: string, @@ -3540,7 +3541,10 @@ export const createEditorSlice: StateCreator = (s }, pushTarget ) - get().setUpstreamStatus(worktreeId, status) + if (options?.applyUpstreamStatus !== false) { + get().setUpstreamStatus(worktreeId, status) + } + return status } catch (error) { // Why: on error we leave the prior status in place rather than writing a // synthetic {hasUpstream:false} — that would flash 'Publish Branch' on a @@ -3549,6 +3553,7 @@ export const createEditorSlice: StateCreator = (s // genuinely newly unpublished, the polling effect will eventually correct // the status on success. console.error('fetchUpstreamStatus failed', error) + return null } }, pushBranch: async ( diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 372ddfd9bb4..823ed97e0ec 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -2967,6 +2967,75 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { }) }) + it('does not direct-fetch when enqueue returns an automatic validation skip', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ + kind: 'skipped', + skippedReason: 'validation-denied' + }) + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false + } + ] + } + } as unknown as Partial) + + store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80) + await Promise.resolve() + + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1) + expect(mockApi.gh.prForBranch).not.toHaveBeenCalled() + }) + + it('direct-fetches when enqueue returns an explicit fallback result', async () => { + const store = createTestStore() + const repoPath = '/repo' + const branch = 'feature/test' + const worktreeId = 'wt-1' + mockApi.gh.enqueuePRRefresh.mockResolvedValueOnce({ kind: 'fallback' }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: 1 }) + + store.setState({ + repos: [{ id: 'repo-1', path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { + 'repo-1': [ + { + id: worktreeId, + repoId: 'repo-1', + path: '/repo/worktrees/test', + branch, + displayName: 'test', + isMainWorktree: false, + isBare: false, + isArchived: false + } + ] + } + } as unknown as Partial) + + store.getState().enqueueGitHubPRRefresh(worktreeId, 'active', 80) + await Promise.resolve() + await Promise.resolve() + + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(1) + expect(mockApi.gh.refreshPRNow).toHaveBeenCalledTimes(1) + }) + it('enqueues active PR refresh with a GitHub hosted-review fallback number', () => { const store = createTestStore() const repoPath = '/repo' diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index cfd032f969d..495eaef72b7 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -3441,7 +3441,7 @@ export const createGitHubSlice: StateCreator = (s if (enqueue) { void enqueue({ candidate, reason, priority }) .then((queued) => { - if (queued === false) { + if (queued === false || queued.kind === 'fallback') { return get().fetchPRForBranch(candidate.repoPath, candidate.branch, { force: bypassesGitHubPRRefreshFreshness(reason), repoId: candidate.repoId, diff --git a/src/renderer/src/store/slices/repo-host-identity.ts b/src/renderer/src/store/slices/repo-host-identity.ts new file mode 100644 index 00000000000..92afdafc3ad --- /dev/null +++ b/src/renderer/src/store/slices/repo-host-identity.ts @@ -0,0 +1,56 @@ +import type { GlobalSettings, Repo } from '../../../../shared/types' +import { + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +type RepoIdentityParts = Pick + +export function getRepoHostIdentity(repo: RepoIdentityParts): string { + return getRepoHostIdentityForParts(repo.id, getRepoExecutionHostId(repo)) +} + +export function getRepoHostIdentityForParts(repoId: string, hostId: string): string { + // Why: host ids and repo ids can contain punctuation; NUL keeps the composite + // key collision-free without escaping user/provider-owned strings. + return `${hostId}\0${repoId}` +} + +export function repoMatchesHostIdentity( + repo: RepoIdentityParts, + repoId: string, + hostId: string +): boolean { + return repo.id === repoId && getRepoExecutionHostId(repo) === hostId +} + +export function findRepoForHost( + repos: readonly RepoIdentityParts[], + repoId: string, + options: { + hostId?: ExecutionHostId | string | null + settings?: Pick | null + } = {} +): RepoIdentityParts | null { + const matchingRepos = repos.filter((repo) => repo.id === repoId) + if (matchingRepos.length === 0) { + return null + } + + if (options.hostId) { + return matchingRepos.find((repo) => getRepoExecutionHostId(repo) === options.hostId) ?? null + } + + if (matchingRepos.length === 1) { + return matchingRepos[0] + } + + const focusedHostId = getSettingsFocusedExecutionHostId(options.settings) + const focusedMatches = matchingRepos.filter( + (repo) => getRepoExecutionHostId(repo) === focusedHostId + ) + // Why: when duplicate ids exist even within the focused host, mutating by bare + // id would be ambiguous. Let callers surface no owner instead of guessing. + return focusedMatches.length === 1 ? focusedMatches[0] : null +} diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts index 71615147156..f8c334ff4a7 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import fs from 'node:fs' import type { Repo } from '../../../../shared/types' import { reconcileFetchedRepos } from './repo-identity-reconcile' @@ -47,4 +48,34 @@ describe('reconcileFetchedRepos', () => { expect(result[0]).toBe(previous[0]) expect(result.map((r) => r.id)).toEqual(['a', 'b']) }) + + it('preserves same-id repo identity only within the matching host', () => { + const previous = [ + makeRepo('same', { executionHostId: 'local', displayName: 'Local' }), + makeRepo('same', { executionHostId: 'runtime:env-1', displayName: 'Remote' }) + ] + const next = [ + makeRepo('same', { executionHostId: 'runtime:env-1', displayName: 'Remote' }), + makeRepo('same', { executionHostId: 'local', displayName: 'Local' }) + ] + + const result = reconcileFetchedRepos(previous, next) + + expect(result[0]).toBe(previous[1]) + expect(result[1]).toBe(previous[0]) + }) + + it('uses map-style host identity lookups for large duplicate refreshes', () => { + const previous = Array.from({ length: 1000 }, (_, index) => + makeRepo(`repo-${index}`, { executionHostId: index % 2 === 0 ? 'local' : 'runtime:env-1' }) + ) + const next = previous.map((repo) => ({ ...repo })) + + const result = reconcileFetchedRepos(previous, next) + + expect(result).toBe(previous) + expect(result.every((repo, index) => repo === previous[index])).toBe(true) + const source = fs.readFileSync(new URL('./repo-identity-reconcile.ts', import.meta.url), 'utf8') + expect(source).not.toMatch(/previous\.find|next\.find|findIndex/) + }) }) diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index 55d313e0cd6..119c4ed3e2a 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -1,4 +1,5 @@ import type { Repo } from '../../../../shared/types' +import { getRepoHostIdentity } from './repo-host-identity' // Why: after a drag-reorder we optimistically set `repos`, persist, and main // broadcasts `repos:changed`. The renderer's own echo handler refetches, which @@ -27,10 +28,10 @@ function areReposEqual(a: Repo, b: Repo): boolean { } export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): Repo[] { - const previousById = new Map(previous.map((repo) => [repo.id, repo])) + const previousById = new Map(previous.map((repo) => [getRepoHostIdentity(repo), repo])) let identical = next.length === previous.length const reconciled = next.map((repo, index) => { - const existing = previousById.get(repo.id) + const existing = previousById.get(getRepoHostIdentity(repo)) if (existing && areReposEqual(existing, repo)) { if (existing !== previous[index]) { identical = false diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.test.ts b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts index 8ba15f5edd9..fd4ace117f3 100644 --- a/src/renderer/src/store/slices/repo-reorder-host-split.test.ts +++ b/src/renderer/src/store/slices/repo-reorder-host-split.test.ts @@ -43,4 +43,15 @@ describe('splitRepoReorderByHost', () => { }) expect(groups).toEqual([{ hostId: 'local', orderedIds: ['a'] }]) }) + + it('routes duplicate bare ids by occurrence in current repo order', () => { + const repos = [repo('same', 'local'), repo('same', 'runtime:env-1')] + const groups = splitRepoReorderByHost(['same', 'same'], repos, { + activeRuntimeEnvironmentId: null + }) + expect(groups).toEqual([ + { hostId: 'local', orderedIds: ['same'] }, + { hostId: 'runtime:env-1', orderedIds: ['same'] } + ]) + }) }) diff --git a/src/renderer/src/store/slices/repo-reorder-host-split.ts b/src/renderer/src/store/slices/repo-reorder-host-split.ts index 2ef975b4646..9924df17c7f 100644 --- a/src/renderer/src/store/slices/repo-reorder-host-split.ts +++ b/src/renderer/src/store/slices/repo-reorder-host-split.ts @@ -9,6 +9,11 @@ export type RepoReorderHostGroup = { orderedIds: string[] } +type RepoHostCursor = { + hosts: string[] + nextIndex: number +} + /** Split a cross-host reorder permutation into per-host permutations. * * Why: each host persists only its own repos and rejects any id list that is not @@ -25,14 +30,26 @@ export function splitRepoReorderByHost( settings: Pick | null | undefined ): RepoReorderHostGroup[] { const focusedHostId = getSettingsFocusedExecutionHostId(settings) - const hostByRepoId = new Map() + const remainingHostsByRepoId = new Map() for (const repo of repos) { const hasExplicitOwner = Boolean(repo.executionHostId?.trim() || repo.connectionId?.trim()) - hostByRepoId.set(repo.id, hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId) + const hostId = hasExplicitOwner ? getRepoExecutionHostId(repo) : focusedHostId + const existing = remainingHostsByRepoId.get(repo.id) + if (existing) { + existing.hosts.push(hostId) + } else { + remainingHostsByRepoId.set(repo.id, { hosts: [hostId], nextIndex: 0 }) + } } const groups = new Map() for (const id of orderedIds) { - const hostId = hostByRepoId.get(id) + const remainingHosts = remainingHostsByRepoId.get(id) + // Why: a bare repo id can appear once per host in the combined order. The + // cursor consumes each occurrence against its owner host exactly once. + const hostId = remainingHosts?.hosts[remainingHosts.nextIndex] + if (remainingHosts) { + remainingHosts.nextIndex += 1 + } if (!hostId) { continue } diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts new file mode 100644 index 00000000000..22690115f32 --- /dev/null +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore, makeWorktree } from './store-test-helpers' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const localDuplicate: Repo = { + id: 'same-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'local' +} + +const remoteDuplicate: Repo = { + id: 'same-repo', + path: '/remote', + displayName: 'Remote', + badgeColor: '#111', + addedAt: 2, + executionHostId: 'runtime:env-1' +} + +const reposRemove = vi.fn() +const reposUpdate = vi.fn() +const reposReorder = vi.fn() +const ptyKill = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function projectHostSetup(overrides: Pick): ProjectHostSetup { + return { + projectId: 'repo:same-repo', + repoId: 'same-repo', + path: '/same-repo', + displayName: 'Same Repo', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposRemove.mockReset() + reposUpdate.mockReset() + reposReorder.mockReset() + ptyKill.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + vi.stubGlobal('window', { + api: { + repos: { + remove: reposRemove, + update: reposUpdate, + reorder: reposReorder + }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall } + } + }) +}) + +describe('repo slice host identity routing', () => { + it('updates only the focused host row when repo ids are duplicated across hosts', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName: 'Remote Renamed' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate] + }) + + await store.getState().updateRepo('same-repo', { displayName: 'Remote Renamed' }) + + expect(store.getState().repos).toEqual([ + localDuplicate, + { ...remoteDuplicate, displayName: 'Remote Renamed' } + ]) + expect(reposUpdate).not.toHaveBeenCalled() + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote Renamed' } }, + timeoutMs: 15_000 + }) + }) + + it('updates a legacy local duplicate without overwriting an explicit remote sibling', async () => { + const { executionHostId: _executionHostId, ...legacyLocalDuplicate } = localDuplicate + reposUpdate.mockResolvedValue(undefined) + const store = createTestStore() + store.setState({ repos: [legacyLocalDuplicate as Repo, remoteDuplicate] }) + + await store.getState().updateRepo('same-repo', { displayName: 'Local Renamed' }) + + expect(store.getState().repos).toEqual([ + { ...legacyLocalDuplicate, displayName: 'Local Renamed' }, + remoteDuplicate + ]) + expect(reposUpdate).toHaveBeenCalledWith({ + repoId: 'same-repo', + updates: { displayName: 'Local Renamed' } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'repo.update' }) + ) + }) + + it('keeps queued focused-host repo updates pinned when focus changes', async () => { + const firstUpdate = deferred<{ + id: string + ok: true + result: { repo: Repo } + _meta: { runtimeId: string } + }>() + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.update') { + const { updates } = (args as unknown as { params: { updates: { displayName: string } } }) + .params + const displayName = updates.displayName + if (displayName === 'Remote slow') { + return firstUpdate.promise + } + return Promise.resolve({ + id: 'rpc-queued-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName } }, + _meta: { runtimeId: 'runtime-remote' } + }) + } + return Promise.resolve({ + id: 'rpc-other', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate] + }) + + const first = store.getState().updateRepo('same-repo', { displayName: 'Remote slow' }) + await vi.waitFor(() => { + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote slow' } } + }) + ) + }) + + const second = store.getState().updateRepo('same-repo', { displayName: 'Remote queued' }) + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-2' } as never }) + firstUpdate.resolve({ + id: 'rpc-first-update', + ok: true, + result: { repo: { ...remoteDuplicate, displayName: 'Remote slow' } }, + _meta: { runtimeId: 'runtime-remote' } + }) + + await expect(first).resolves.toBe(true) + await expect(second).resolves.toBe(true) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'env-1', + method: 'repo.update', + params: { repo: 'same-repo', updates: { displayName: 'Remote queued' } } + }) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-2', method: 'repo.update' }) + ) + expect(store.getState().repos).toEqual([ + localDuplicate, + { ...remoteDuplicate, displayName: 'Remote queued' } + ]) + }) + + it('removes only the focused host row and worktrees for duplicate repo ids', async () => { + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo' + }) + const remoteWorktree = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + repos: [localDuplicate, remoteDuplicate], + projects: [ + { + id: 'repo:same-repo', + displayName: 'Same Repo', + badgeColor: '#000', + sourceRepoIds: ['same-repo'], + createdAt: 1, + updatedAt: 1 + } satisfies Project + ], + projectHostSetups: [ + projectHostSetup({ id: 'local-setup', hostId: 'local' }), + projectHostSetup({ id: 'remote-setup', hostId: 'runtime:env-1' }) + ], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + tabsByWorktree: { + [localWorktree.id]: [{ id: 'local-tab', worktreeId: localWorktree.id }] as never, + [remoteWorktree.id]: [{ id: 'remote-tab', worktreeId: remoteWorktree.id }] as never + }, + ptyIdsByTabId: { + 'local-tab': ['local-pty'], + 'remote-tab': ['remote-pty'] + }, + lastVisitedAtByWorktreeId: { + [localWorktree.id]: 10, + [remoteWorktree.id]: 20 + } + }) + + await store.getState().removeProject('same-repo') + + expect(store.getState().repos).toEqual([remoteDuplicate]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([remoteWorktree]) + expect(store.getState().tabsByWorktree[localWorktree.id]).toBeUndefined() + expect(store.getState().tabsByWorktree[remoteWorktree.id]).toEqual([ + { id: 'remote-tab', worktreeId: remoteWorktree.id } + ]) + expect(store.getState().projectHostSetups).toEqual([ + expect.objectContaining({ hostId: 'runtime:env-1', repoId: 'same-repo' }) + ]) + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [remoteWorktree.id]: 20 }) + expect(store.getState().projects).toEqual([ + expect.objectContaining({ id: 'repo:same-repo', sourceRepoIds: ['same-repo'] }) + ]) + expect(reposRemove).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(ptyKill).toHaveBeenCalledWith('local-pty') + expect(ptyKill).not.toHaveBeenCalledWith('remote-pty') + }) + + it('removes a runtime duplicate without purging legacy local worktrees', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-remove', + ok: true, + result: { status: 'removed' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo' + }) + const remoteWorktree = makeWorktree({ + id: 'same-repo::/remote/wt', + repoId: 'same-repo', + hostId: 'runtime:env-1' + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [localDuplicate, remoteDuplicate], + worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] }, + tabsByWorktree: { + [localWorktree.id]: [{ id: 'local-tab', worktreeId: localWorktree.id }] as never, + [remoteWorktree.id]: [{ id: 'remote-tab', worktreeId: remoteWorktree.id }] as never + }, + ptyIdsByTabId: { + 'local-tab': ['local-pty'], + 'remote-tab': ['remote-pty'] + }, + lastVisitedAtByWorktreeId: { + [localWorktree.id]: 10, + [remoteWorktree.id]: 20 + } + }) + + await store.getState().removeProject('same-repo') + + expect(store.getState().repos).toEqual([localDuplicate]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) + expect(store.getState().tabsByWorktree[localWorktree.id]).toEqual([ + { id: 'local-tab', worktreeId: localWorktree.id } + ]) + expect(store.getState().tabsByWorktree[remoteWorktree.id]).toBeUndefined() + expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktree.id]: 10 }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.rm', + params: { repo: 'same-repo' }, + timeoutMs: 15_000 + }) + expect(reposRemove).not.toHaveBeenCalled() + expect(ptyKill).toHaveBeenCalledWith('remote-pty') + expect(ptyKill).not.toHaveBeenCalledWith('local-pty') + }) + + it('reorders duplicate repo ids once per owning host', async () => { + reposReorder.mockResolvedValue({ status: 'applied' }) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-reorder', + ok: true, + result: { status: 'applied' }, + _meta: { runtimeId: 'runtime-remote' } + }) + const store = createTestStore() + store.setState({ repos: [localDuplicate, remoteDuplicate] }) + + await store.getState().reorderRepos(['same-repo', 'same-repo']) + + expect(store.getState().repos).toEqual([localDuplicate, remoteDuplicate]) + expect(reposReorder).toHaveBeenCalledWith({ orderedIds: ['same-repo'] }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'repo.reorder', + params: { orderedIds: ['same-repo'] }, + timeoutMs: 15_000 + }) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6465697299f..16258886392 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -44,9 +44,14 @@ import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' import { selectProjectGroupRemovalTargets } from './project-group-removal-targets' -import { getRepoIdFromWorktreeId } from './worktree-helpers' import { reconcileFetchedRepos } from './repo-identity-reconcile' import { splitRepoReorderByHost } from './repo-reorder-host-split' +import { + findRepoForHost, + getRepoHostIdentity, + getRepoHostIdentityForParts, + repoMatchesHostIdentity +} from './repo-host-identity' import { assertRuntimeEnvironmentCapability, callRuntimeRpc, @@ -56,7 +61,6 @@ import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-clien import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' import { translate } from '@/i18n/i18n' @@ -187,13 +191,21 @@ function getRepoUpdateChains(get: () => AppState): Map> return chains } -function getKnownRepoWorktreeIds(state: AppState, projectId: string): string[] { +function worktreeBelongsToHost(worktree: { hostId?: string }, hostId: string): boolean { + return (worktree.hostId ?? LOCAL_EXECUTION_HOST_ID) === hostId +} + +function getKnownRepoWorktreeIds(state: AppState, projectId: string, hostId?: string): string[] { const ids = new Set() for (const worktree of state.worktreesByRepo[projectId] ?? []) { - ids.add(worktree.id) + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } } for (const worktree of state.detectedWorktreesByRepo[projectId]?.worktrees ?? []) { - ids.add(worktree.id) + if (!hostId || worktreeBelongsToHost(worktree, hostId)) { + ids.add(worktree.id) + } } return [...ids] } @@ -460,21 +472,34 @@ function getCurrentSourceRepoIds(project: Project, currentRepoIds: ReadonlySet currentRepoIds.has(repoId)) } +function getReposById(repos: readonly Repo[]): Map { + const reposById = new Map() + for (const repo of repos) { + const existing = reposById.get(repo.id) + if (existing) { + existing.push(repo) + } else { + reposById.set(repo.id, [repo]) + } + } + return reposById +} + function getSourceRepoIdsOutsideHost( project: Project, - reposById: ReadonlyMap, + reposById: ReadonlyMap, hostId: string ): string[] { return project.sourceRepoIds.filter((repoId) => { - const repo = reposById.get(repoId) - return repo && getRepoExecutionHostId(repo) !== hostId + const repos = reposById.get(repoId) ?? [] + return repos.some((repo) => getRepoExecutionHostId(repo) !== hostId) }) } function getMergedSourceRepoIdsForHostRefresh( previous: Project, current: Project, - reposById: ReadonlyMap, + reposById: ReadonlyMap, hostId: string ): string[] { return [ @@ -498,7 +523,7 @@ function projectWithCurrentSourceRepoIds( function mergePreviousProjectMetadata( previous: Project, current: Project, - reposById: ReadonlyMap, + reposById: ReadonlyMap, hostId: string ): Project { const project = mergeProjectCompatibilityProject(previous, current) @@ -535,7 +560,7 @@ function mergeProjectHostSetupCompatibility( const derivedSetups = derived.projectHostSetups.filter( (setup) => !fetchedSetupOwners.has(getProjectHostSetupOwnerKey(setup)) ) - const projectHostSetups = mergeById(derivedSetups, fetched.setups) + const projectHostSetups = mergeProjectHostSetupsByOwner(derivedSetups, fetched.setups) const setupProjectIds = new Set(projectHostSetups.map((setup) => setup.projectId)) const fetchedProjectIds = new Set(fetched.projects.map((project) => project.id)) return { @@ -547,7 +572,27 @@ function mergeProjectHostSetupCompatibility( } function getProjectHostSetupOwnerKey(setup: ProjectHostSetup): string { - return `${setup.hostId}:${setup.repoId ?? setup.id}` + return `${setup.hostId}:${setup.repoId || setup.id}` +} + +function mergeProjectHostSetupsByOwner( + base: readonly ProjectHostSetup[], + overlay: readonly ProjectHostSetup[] +): ProjectHostSetup[] { + const merged = [...base] + const indexByOwner = new Map( + merged.map((entry, index) => [getProjectHostSetupOwnerKey(entry), index]) + ) + for (const entry of overlay) { + const index = indexByOwner.get(getProjectHostSetupOwnerKey(entry)) + if (index === undefined) { + indexByOwner.set(getProjectHostSetupOwnerKey(entry), merged.length) + merged.push(entry) + } else { + merged[index] = entry + } + } + return merged } function getProjectHostIds( @@ -568,15 +613,14 @@ function getExplicitProjectHostIds( repos: readonly Repo[] ): Set { const hostIds = new Set() - const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const sourceRepoIds = new Set(project.sourceRepoIds) for (const setup of setups) { if (setup.projectId === project.id) { hostIds.add(setup.hostId) } } - for (const repoId of project.sourceRepoIds) { - const repo = repoById.get(repoId) - if (repo) { + for (const repo of repos) { + if (sourceRepoIds.has(repo.id)) { hostIds.add(getRepoExecutionHostId(repo)) } } @@ -596,9 +640,9 @@ function mergeFetchedProjectCompatibilityForHost({ }): Pick { const fetchedSetupsForHost = fetched.projectHostSetups.filter((setup) => setup.hostId === hostId) const preservedSetups = previous.projectHostSetups.filter((setup) => setup.hostId !== hostId) - const projectHostSetups = mergeById(preservedSetups, fetchedSetupsForHost) + const projectHostSetups = mergeProjectHostSetupsByOwner(preservedSetups, fetchedSetupsForHost) const previousProjectById = new Map(previous.projects.map((project) => [project.id, project])) - const reposById = new Map(repos.map((repo) => [repo.id, repo])) + const reposById = getReposById(repos) const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectHasHost = (project: Project, setups: readonly ProjectHostSetup[]): boolean => getProjectHostIds(project, setups, repos).has(hostId) @@ -663,25 +707,41 @@ function mergeFetchedReposForHost( fetched: Repo[], hostId: string ): Repo[] { - const fetchedIds = new Set(fetched.map((repo) => repo.id)) + const fetchedIdentities = new Set(fetched.map(getRepoHostIdentity)) const preserved = previous.filter((repo) => { const existingHostId = getRepoExecutionHostId(repo) - return existingHostId !== hostId || fetchedIds.has(repo.id) + return existingHostId !== hostId || fetchedIdentities.has(getRepoHostIdentity(repo)) }) - const preservedById = new Map(preserved.map((repo) => [repo.id, repo])) const merged = [...preserved] + const indexByIdentity = new Map(merged.map((repo, index) => [getRepoHostIdentity(repo), index])) for (const repo of fetched) { - const existingIndex = merged.findIndex((entry) => entry.id === repo.id) - if (existingIndex === -1) { + const identity = getRepoHostIdentity(repo) + const existingIndex = indexByIdentity.get(identity) + if (existingIndex === undefined) { + indexByIdentity.set(identity, merged.length) merged.push(repo) continue } merged[existingIndex] = repo } - return reconcileFetchedRepos( + return reconcileFetchedRepos(previous, merged) +} + +function mergeProjectCompatibilityForHostRepoChange({ + previous, + nextRepos, + hostId +}: { + previous: Pick + nextRepos: readonly Repo[] + hostId: string +}): Pick { + return mergeFetchedProjectCompatibilityForHost({ previous, - merged.filter((repo) => preservedById.has(repo.id) || fetchedIds.has(repo.id)) - ) + fetched: projectCompatibilityFromRepos(nextRepos), + repos: nextRepos, + hostId + }) } function getProjectGroupHostId(group: Pick) { @@ -821,7 +881,26 @@ async function listRuntimeEnvironmentsForAllHostLoad(): Promise<{ id: string }[] } function settingsForRepoOwner(state: Pick, repoId: string) { - return getSettingsForRepoRuntimeOwner(state, repoId) as AppState['settings'] + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) + if (!repo) { + return state.settings + } + if (!repo.executionHostId && !repo.connectionId) { + return state.settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + if ( + (parsed?.kind === 'local' || parsed?.kind === 'ssh') && + state.settings?.activeRuntimeEnvironmentId + ) { + return { ...state.settings, activeRuntimeEnvironmentId: null } + } + return state.settings } function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string { @@ -1712,6 +1791,9 @@ export const createRepoSlice: StateCreator = (set, moveProjectToGroup: async (projectId, groupId, order) => { try { + if (!findRepoForHost(get().repos, projectId, { settings: get().settings })) { + return false + } const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const moved = target.kind === 'local' @@ -1732,10 +1814,21 @@ export const createRepoSlice: StateCreator = (set, return false } const ownedMoved = repoWithFetchedOwner(moved, target) - set((s) => ({ - repos: s.repos.map((repo) => (repo.id === projectId ? ownedMoved : repo)), - folderWorkspacePathStatuses: {} - })) + const movedHostId = getRepoExecutionHostId(ownedMoved) + set((s) => { + const nextRepos = s.repos.map((repo) => + repoMatchesHostIdentity(repo, projectId, movedHostId) ? ownedMoved : repo + ) + return { + repos: nextRepos, + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: movedHostId + }), + folderWorkspacePathStatuses: {} + } + }) return true } catch (err) { console.error('Failed to move repo to group:', err) @@ -1778,18 +1871,24 @@ export const createRepoSlice: StateCreator = (set, return null } repo = repoWithFetchedOwner(repo, target) - const alreadyAdded = get().repos.some((r) => r.id === repo.id) + const repoIdentity = getRepoHostIdentity(repo) + const alreadyAdded = get().repos.some((r) => getRepoHostIdentity(r) === repoIdentity) if (alreadyAdded) { get().clearOrcaHookTrustForRepo(repo.id) } set((s) => { - if (s.repos.some((r) => r.id === repo.id)) { + if (s.repos.some((r) => getRepoHostIdentity(r) === repoIdentity)) { return s } const nextRepos = [...s.repos, repo] + const hostId = getRepoExecutionHostId(repo) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId + }), folderWorkspacePathStatuses: {} } }) @@ -1836,10 +1935,15 @@ export const createRepoSlice: StateCreator = (set, ) ).result const repo = repoWithFetchedOwner(result.repo, target) + const repoHostId = getRepoExecutionHostId(repo) const setup = setupWithFetchedOwner(result.setup, target) set((s) => { - const nextRepos = s.repos.some((entry) => entry.id === repo.id) - ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + const nextRepos = s.repos.some((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) + ) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry + ) : [...s.repos, repo] const nextProjects = s.projects.some((entry) => entry.id === result.project.id) ? s.projects.map((entry) => (entry.id === result.project.id ? result.project : entry)) @@ -1924,10 +2028,13 @@ export const createRepoSlice: StateCreator = (set, ).result const setup = setupWithFetchedOwner(result.setup, target) const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => ({ repos: repo - ? s.repos.some((entry) => entry.id === repo.id) - ? s.repos.map((entry) => (entry.id === repo.id ? repo : entry)) + ? s.repos.some((entry) => repoMatchesHostIdentity(entry, repo.id, repoHostId!)) + ? s.repos.map((entry) => + repoMatchesHostIdentity(entry, repo.id, repoHostId!) ? repo : entry + ) : [...s.repos, repo] : s.repos, projects: s.projects.some((entry) => entry.id === result.project.id) @@ -1968,11 +2075,15 @@ export const createRepoSlice: StateCreator = (set, ) ).result const repo = result.repo ? repoWithFetchedOwner(result.repo, target) : undefined + const repoHostId = repo ? getRepoExecutionHostId(repo) : null set((s) => { const projectHostSetups = s.projectHostSetups.filter( (setup) => setup.id !== result.setup.id ) - const repos = repo ? s.repos.filter((entry) => entry.id !== repo.id) : s.repos + const repos = + repo && repoHostId + ? s.repos.filter((entry) => !repoMatchesHostIdentity(entry, repo.id, repoHostId)) + : s.repos const projects = repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) ? s.projects.filter((project) => project.id !== result.project.id) @@ -2106,19 +2217,26 @@ export const createRepoSlice: StateCreator = (set, removeProject: async (projectId) => { try { + const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings }) + if (!ownerRepo) { + return + } + const ownerHostId = getRepoExecutionHostId(ownerRepo) const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) await (target.kind === 'local' ? window.api.repos.remove({ repoId: projectId }) : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) get().clearOrcaHookTrustForRepo(projectId) - const repoPath = get().repos.find((repo) => repo.id === projectId)?.path + const repoPath = get().repos.find((repo) => + repoMatchesHostIdentity(repo, projectId, ownerHostId) + )?.path get().evictGitHubRepoCaches(projectId, repoPath) const { clearRepoSlugCacheEntry } = await import('../../lib/repo-slug-index') clearRepoSlugCacheEntry(projectId) // Kill PTYs for all worktrees belonging to this repo - const worktreeIds = getKnownRepoWorktreeIds(get(), projectId) + const worktreeIds = getKnownRepoWorktreeIds(get(), projectId, ownerHostId) const killedTabIds = new Set() const killedPtyIds = new Set() if (target.kind === 'environment') { @@ -2155,9 +2273,26 @@ export const createRepoSlice: StateCreator = (set, set((s) => { const nextWorktrees = { ...s.worktreesByRepo } - delete nextWorktrees[projectId] + const remainingWorktrees = (nextWorktrees[projectId] ?? []).filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingWorktrees.length > 0) { + nextWorktrees[projectId] = remainingWorktrees + } else { + delete nextWorktrees[projectId] + } const nextDetectedWorktrees = { ...s.detectedWorktreesByRepo } - delete nextDetectedWorktrees[projectId] + const detected = nextDetectedWorktrees[projectId] + if (detected) { + const remainingDetected = detected.worktrees.filter( + (worktree) => !worktreeBelongsToHost(worktree, ownerHostId) + ) + if (remainingDetected.length > 0) { + nextDetectedWorktrees[projectId] = { ...detected, worktrees: remainingDetected } + } else { + delete nextDetectedWorktrees[projectId] + } + } const nextTabs = { ...s.tabsByWorktree } const nextLayouts = { ...s.terminalLayoutsByTabId } const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } @@ -2195,17 +2330,21 @@ export const createRepoSlice: StateCreator = (set, // forever after the repo is removed. let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) { - if (getRepoIdFromWorktreeId(id) === projectId) { + if (worktreeIdSet.has(id)) { if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) { nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId } } delete nextLastVisitedAtByWorktreeId[id] } } - const nextRepos = s.repos.filter((r) => r.id !== projectId) + const nextRepos = s.repos.filter((r) => !repoMatchesHostIdentity(r, projectId, ownerHostId)) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), activeRepoId: s.activeRepoId === projectId ? null : s.activeRepoId, filterRepoIds: s.filterRepoIds.filter((id) => id !== projectId), worktreesByRepo: nextWorktrees, @@ -2282,10 +2421,25 @@ export const createRepoSlice: StateCreator = (set, updateRepo: async (projectId, updates) => { const updateRepoChains = getRepoUpdateChains(get) + const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings }) + if (!ownerRepo) { + return false + } + const ownerHasExplicitHost = Boolean( + ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim() + ) + const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo) + const ownerTarget = ownerHasExplicitHost + ? getProjectSetupRuntimeTarget(explicitOwnerHostId) + : getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const ownerHostId = ownerHasExplicitHost + ? explicitOwnerHostId + : getRuntimeTargetHostId(ownerTarget) + const updateChainKey = getRepoHostIdentityForParts(projectId, ownerHostId) const applyRepoUpdate = async () => { try { const sanitizedUpdates = sanitizeRepoUpdate(updates) - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) + const target = ownerTarget const updatedRepo = target.kind === 'local' ? await window.api.repos.update({ repoId: projectId, updates: sanitizedUpdates }) @@ -2299,7 +2453,10 @@ export const createRepoSlice: StateCreator = (set, ).repo set((s) => { const nextRepos = s.repos.map((r) => { - if (r.id !== projectId) { + const matchesOwner = ownerHasExplicitHost + ? repoMatchesHostIdentity(r, projectId, ownerHostId) + : repoMatchesHostIdentity(r, projectId, ownerHostId) || r === ownerRepo + if (!matchesOwner) { return r } if (updatedRepo) { @@ -2320,7 +2477,11 @@ export const createRepoSlice: StateCreator = (set, }) return { repos: nextRepos, - ...projectCompatibilityFromRepos(nextRepos), + ...mergeProjectCompatibilityForHostRepoChange({ + previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + nextRepos, + hostId: ownerHostId + }), folderWorkspacePathStatuses: {} } }) @@ -2330,16 +2491,16 @@ export const createRepoSlice: StateCreator = (set, return false } } - const previous = updateRepoChains.get(projectId) + const previous = updateRepoChains.get(updateChainKey) // Why: repo settings are persisted as full nested values. Preserve call // order per repo so a slower IPC/RPC response cannot overwrite newer state. const next = previous ? previous.catch(() => undefined).then(applyRepoUpdate) : applyRepoUpdate() - updateRepoChains.set(projectId, next) + updateRepoChains.set(updateChainKey, next) const cleanup = () => { - if (updateRepoChains.get(projectId) === next) { - updateRepoChains.delete(projectId) + if (updateRepoChains.get(updateChainKey) === next) { + updateRepoChains.delete(updateChainKey) } } void next.then(cleanup, cleanup) @@ -2352,10 +2513,22 @@ export const createRepoSlice: StateCreator = (set, // Optimistically apply the new order so the sidebar updates instantly; // resync only if main rejects (stale permutation due to a racing add/remove). const previous = get().repos - const byId = new Map(previous.map((r) => [r.id, r])) + const remainingById = new Map() + for (const repo of previous) { + const existing = remainingById.get(repo.id) + if (existing) { + existing.repos.push(repo) + } else { + remainingById.set(repo.id, { repos: [repo], nextIndex: 0 }) + } + } const next: Repo[] = [] for (const id of orderedIds) { - const repo = byId.get(id) + const remaining = remainingById.get(id) + const repo = remaining?.repos[remaining.nextIndex] + if (remaining) { + remaining.nextIndex += 1 + } if (repo) { next.push(repo) } @@ -2366,7 +2539,6 @@ export const createRepoSlice: StateCreator = (set, } set({ repos: next, - ...projectCompatibilityFromRepos(next), folderWorkspacePathStatuses: {} }) try { @@ -2392,11 +2564,11 @@ export const createRepoSlice: StateCreator = (set, }) ) if (results.some((result) => result.status === 'rejected')) { - await get().fetchRepos() + await get().fetchReposForAllHosts() } } catch (err) { console.error('Failed to reorder repos:', err) - await get().fetchRepos() + await get().fetchReposForAllHosts() } } }) diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 358d9b9598c..4bbcc75cd2d 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -851,6 +851,48 @@ describe('TabsSlice', () => { // on that group's active terminal tab. Without this, the bell lingers // until the tab is clicked a second time. describe('focusGroup', () => { + it('does not broadcast active-surface writes when the focused group is already current', () => { + const editorFileId = '/tmp/feature/src/main.ts' + const tab = store.getState().createUnifiedTab(WT, 'editor', { + id: 'editor-tab-1', + entityId: editorFileId, + label: 'main.ts' + }) + const groupId = store.getState().groupsByWorktree[WT][0].id + store.setState({ + activeWorktreeId: WT, + openFiles: [makeOpenFile({ id: editorFileId, worktreeId: WT })], + activeGroupIdByWorktree: { [WT]: groupId }, + activeFileId: editorFileId, + activeFileIdByWorktree: { [WT]: editorFileId }, + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: { [WT]: null }, + activeTabId: null, + activeTabIdByWorktree: { [WT]: null }, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WT]: 'editor' }, + groupsByWorktree: { + [WT]: [ + { + ...store.getState().groupsByWorktree[WT][0], + activeTabId: tab.id + } + ] + } + }) + const before = store.getState() + const listener = vi.fn() + const unsubscribe = store.subscribe(listener) + + store.getState().focusGroup(WT, groupId) + unsubscribe() + + expect(listener).not.toHaveBeenCalled() + expect(store.getState().activeGroupIdByWorktree).toBe(before.activeGroupIdByWorktree) + expect(store.getState().activeFileIdByWorktree).toBe(before.activeFileIdByWorktree) + expect(store.getState().activeTabTypeByWorktree).toBe(before.activeTabTypeByWorktree) + }) + // Why: focusGroup is fired on every pointerdown within a split group's // chrome (onPointerDown + onFocusCapture in TabGroupPanel). Clearing the // tab-level bell here is fine — the user is now looking at this group. diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 04e83253973..fd54c54cde7 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -534,6 +534,44 @@ function buildActiveSurfacePatch( } } +function activeSurfacePatchMatchesState( + state: Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + >, + worktreeId: string, + patch: Pick< + AppState, + | 'activeBrowserTabId' + | 'activeBrowserTabIdByWorktree' + | 'activeFileId' + | 'activeFileIdByWorktree' + | 'activeTabId' + | 'activeTabIdByWorktree' + | 'activeTabType' + | 'activeTabTypeByWorktree' + > +): boolean { + return ( + state.activeBrowserTabId === patch.activeBrowserTabId && + state.activeBrowserTabIdByWorktree[worktreeId] === + patch.activeBrowserTabIdByWorktree[worktreeId] && + state.activeFileId === patch.activeFileId && + state.activeFileIdByWorktree[worktreeId] === patch.activeFileIdByWorktree[worktreeId] && + state.activeTabId === patch.activeTabId && + state.activeTabIdByWorktree[worktreeId] === patch.activeTabIdByWorktree[worktreeId] && + state.activeTabType === patch.activeTabType && + state.activeTabTypeByWorktree[worktreeId] === patch.activeTabTypeByWorktree[worktreeId] + ) +} + export const createTabsSlice: StateCreator = (set, get) => ({ unifiedTabsByWorktree: {}, renamingTabId: null, @@ -1171,10 +1209,13 @@ export const createTabsSlice: StateCreator = (set, focusGroup: (worktreeId, groupId) => set((state) => { - const nextActiveGroupIdByWorktree = { - ...state.activeGroupIdByWorktree, - [worktreeId]: groupId - } + const groupAlreadyFocused = state.activeGroupIdByWorktree[worktreeId] === groupId + const nextActiveGroupIdByWorktree = groupAlreadyFocused + ? state.activeGroupIdByWorktree + : { + ...state.activeGroupIdByWorktree, + [worktreeId]: groupId + } // Why: focusing a split group surfaces whichever terminal tab is already // active in that group, so the tab-level bell is no longer needed. // @@ -1185,6 +1226,9 @@ export const createTabsSlice: StateCreator = (set, // before the user ever sees the tab. All current callers only fire for // the active worktree, but this guard prevents future misuse. if (state.activeWorktreeId !== worktreeId) { + if (groupAlreadyFocused) { + return state + } return { activeGroupIdByWorktree: nextActiveGroupIdByWorktree } @@ -1214,8 +1258,23 @@ export const createTabsSlice: StateCreator = (set, return changed ? copy : state.unreadTerminalTabs })() : state.unreadTerminalTabs + const activeSurfacePatch = buildActiveSurfacePatch( + { + ...state, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree + }, + worktreeId, + groupId + ) + if ( + groupAlreadyFocused && + nextUnreadTerminalTabs === state.unreadTerminalTabs && + activeSurfacePatchMatchesState(state, worktreeId, activeSurfacePatch) + ) { + return state + } return { - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + ...(groupAlreadyFocused ? {} : { activeGroupIdByWorktree: nextActiveGroupIdByWorktree }), // Why: only write unreadTerminalTabs back into state when it actually // changed. The IIFE above returns state.unreadTerminalTabs by reference // on no-op; preserving that reference via conditional spread keeps @@ -1224,14 +1283,7 @@ export const createTabsSlice: StateCreator = (set, ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs ? { unreadTerminalTabs: nextUnreadTerminalTabs } : {}), - ...buildActiveSurfacePatch( - { - ...state, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree - }, - worktreeId, - groupId - ) + ...activeSurfacePatch } }), diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index ceb59dd752a..5e0c78c2945 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -117,6 +117,12 @@ export type PendingSidebarWorktreeReveal = { beginRename?: boolean } +export type PendingSidebarRowReveal = { + rowKey: string + behavior: 'auto' | 'smooth' + highlight?: boolean +} + export type AgentSendPopoverTargetMode = { id: string instanceId: string @@ -847,6 +853,7 @@ export type UISlice = { petSize: number setPetSize: (size: number) => void pendingRevealWorktree: PendingSidebarWorktreeReveal | null + pendingRevealSidebarRow: PendingSidebarRowReveal | null revealWorktreeInSidebar: ( worktreeId: string, options?: { @@ -855,7 +862,15 @@ export type UISlice = { beginRename?: boolean } ) => void + revealSidebarRow: ( + rowKey: string, + options?: { + behavior?: PendingSidebarRowReveal['behavior'] + highlight?: boolean + } + ) => void clearPendingRevealWorktreeId: () => void + clearPendingRevealSidebarRow: () => void // Why: lets the SourceControl sidebar request that the diff editor scroll // to a specific note. Cleared by the diff decorator after it reveals the // line, so the same id can be requested again later without the surface @@ -2145,6 +2160,7 @@ export const createUISlice: StateCreator = (set, get) }), pendingRevealWorktree: null, + pendingRevealSidebarRow: null, revealWorktreeInSidebar: (worktreeId, options) => set({ pendingRevealWorktree: { @@ -2154,7 +2170,16 @@ export const createUISlice: StateCreator = (set, get) ...(options?.beginRename ? { beginRename: true } : {}) } }), + revealSidebarRow: (rowKey, options) => + set({ + pendingRevealSidebarRow: { + rowKey, + behavior: options?.behavior ?? 'smooth', + ...(options?.highlight === false ? {} : { highlight: true }) + } + }), clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }), + clearPendingRevealSidebarRow: () => set({ pendingRevealSidebarRow: null }), scrollToDiffCommentId: null, setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }), persistedUIReady: false, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index c76cc38406a..00e8c7903b2 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -4285,6 +4285,89 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) + it('preserves sibling host worktrees during hydrated refresh when repo ids are duplicated', async () => { + const store = createTestStore() + const localRepo = { + id: 'same-repo', + path: '/repos/local', + displayName: 'same local', + badgeColor: '#000', + addedAt: 0, + executionHostId: 'local' + } + const runtimeRepo = { + id: 'same-repo', + path: '/repos/remote', + displayName: 'same remote', + badgeColor: '#111', + addedAt: 1, + executionHostId: 'runtime:env-1' + } + const localWorktree = makeWorktree({ + id: 'same-repo::/local/wt', + repoId: 'same-repo', + path: '/local/wt' + }) + const staleRemoteWorktree = makeWorktree({ + id: 'same-repo::/remote/stale', + repoId: 'same-repo', + path: '/remote/stale', + hostId: 'runtime:env-1' + }) + const refreshedRemoteWorktree = makeWorktree({ + id: 'same-repo::/remote/fresh', + repoId: 'same-repo', + path: '/remote/fresh', + hostId: 'local' + }) + + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('same-repo', [localWorktree]) + ) + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-duplicate-worktrees', + ok: true, + result: makeDetectedResult('same-repo', [refreshedRemoteWorktree]), + _meta: { runtimeId: 'runtime-remote' } + }) + + store.setState({ + hasHydratedWorktreePurge: true, + repos: [localRepo, runtimeRepo], + worktreesByRepo: { + 'same-repo': [localWorktree, staleRemoteWorktree] + }, + detectedWorktreesByRepo: { + 'same-repo': makeDetectedResult('same-repo', [localWorktree, staleRemoteWorktree]) + } + } as unknown as Partial) + + await store.getState().fetchAllWorktrees() + + const refreshed = store.getState().worktreesByRepo['same-repo'] ?? [] + expect(refreshed).toHaveLength(2) + expect(refreshed).toEqual( + expect.arrayContaining([ + localWorktree, + { ...refreshedRemoteWorktree, hostId: 'runtime:env-1' } + ]) + ) + expect(refreshed.map((worktree) => worktree.id)).not.toContain(staleRemoteWorktree.id) + expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: localWorktree.id }), + expect.objectContaining({ id: refreshedRemoteWorktree.id, hostId: 'runtime:env-1' }) + ]) + ) + expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'worktree.detectedList', + params: { repo: 'same-repo' }, + timeoutMs: 15_000 + }) + }) + it('bounds concurrent repo scans during hydration-time refresh', async () => { const store = createTestStore() const repos = Array.from({ length: 7 }, (_, index) => ({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index b44bece7790..2c9b3ef5e67 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -24,6 +24,7 @@ import { getRepoIdFromWorktreeId, type WorktreeSlice } from './worktree-helpers' +import { findRepoForHost } from './repo-host-identity' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { @@ -72,11 +73,11 @@ const detachedHeadAutoDerivedDisplayNames = new Map() const folderWorkspaceWorktreeCache = new WeakMap() const hostedReviewPushTargetLookupsInFlight = new Set() -async function mapReposForWorktreeRefresh( - repos: readonly { id: string }[], - mapper: (repo: { id: string }) => Promise -): Promise { - const results = Array(repos.length) +async function mapReposForWorktreeRefresh( + repos: readonly TRepo[], + mapper: (repo: TRepo) => Promise +): Promise { + const results = Array(repos.length) let nextIndex = 0 const workerCount = Math.min(WORKTREE_REFRESH_CONCURRENCY, repos.length) @@ -326,8 +327,12 @@ function withRepoHostId( return hostId === LOCAL_EXECUTION_HOST_ID ? worktree : { ...worktree, hostId } } -function repoHostId(state: Pick, repoId: string): ExecutionHostId { - const repo = state.repos.find((entry) => entry.id === repoId) +function repoHostId( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null +): ExecutionHostId { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID } @@ -345,19 +350,133 @@ function getHydratedSessionWorktreeIdsForRepo(state: AppState, repoId: string): return Object.keys(state.tabsByWorktree).filter((id) => getRepoIdFromWorktreeId(id) === repoId) } -function getKnownWorktreeIdsForPurge(state: AppState, repoId: string): string[] { +type WorktreeHostMatchOptions = { + unhostedWorktreesMatchHost?: boolean +} + +type RepoHostSummary = { + count: number + onlyHostId?: ExecutionHostId +} + +const repoHostSummariesByRepos = new WeakMap>() + +function getRepoHostSummaries(repos: AppState['repos']): Map { + const cached = repoHostSummariesByRepos.get(repos) + if (cached) { + return cached + } + + const summaries = new Map() + for (const repo of repos) { + const current = summaries.get(repo.id) + if (current) { + summaries.set(repo.id, { count: current.count + 1 }) + } else { + summaries.set(repo.id, { count: 1, onlyHostId: getRepoExecutionHostId(repo) }) + } + } + repoHostSummariesByRepos.set(repos, summaries) + return summaries +} + +function unhostedWorktreesMatchRefreshHost( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): boolean { + if (hostId === LOCAL_EXECUTION_HOST_ID) { + return true + } + + const summary = getRepoHostSummaries(state.repos).get(repoId) + return summary?.count === 1 && summary.onlyHostId === hostId +} + +function worktreeHostMatchOptions( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): WorktreeHostMatchOptions { + return { + // Why: pre-host persisted runtime/SSH worktrees were stored without hostId. + // Treat them as the sole repo owner's rows, but keep ambiguous duplicates local. + unhostedWorktreesMatchHost: unhostedWorktreesMatchRefreshHost(state, repoId, hostId) + } +} + +function worktreeMatchesHost( + worktree: { hostId?: ExecutionHostId }, + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions = {} +): boolean { + if (worktree.hostId) { + return worktree.hostId === hostId + } + return options.unhostedWorktreesMatchHost ?? hostId === LOCAL_EXECUTION_HOST_ID +} + +function mergeWorktreesForHost( + current: readonly T[] | undefined, + refreshed: readonly T[], + hostId: ExecutionHostId, + options?: WorktreeHostMatchOptions +): T[] { + // Why: host-scoped refreshes should replace that host in place so alternating + // local/runtime refreshes do not churn sibling row order or sortEpoch. + const existing = current ?? [] + const next: T[] = [] + let inserted = false + + for (const worktree of existing) { + if (worktreeMatchesHost(worktree, hostId, options)) { + if (!inserted) { + next.push(...refreshed) + inserted = true + } + continue + } + next.push(worktree) + } + + return inserted ? next : [...next, ...refreshed] +} + +function mergeDetectedWorktreesForHost( + current: DetectedWorktreeListResult | undefined, + refreshed: DetectedWorktreeListResult, + hostId: ExecutionHostId, + options?: WorktreeHostMatchOptions +): DetectedWorktreeListResult { + const refreshedForHost = refreshed.worktrees.map((worktree) => withRepoHostId(worktree, hostId)) + return { + ...refreshed, + worktrees: mergeWorktreesForHost(current?.worktrees, refreshedForHost, hostId, options) + } +} + +function getKnownWorktreeIdsForPurge( + state: AppState, + repoId: string, + hostId: ExecutionHostId +): string[] { const detected = state.detectedWorktreesByRepo[repoId] const knownIds = new Set() + const matchOptions = worktreeHostMatchOptions(state, repoId, hostId) if (detected?.authoritative === true) { for (const worktree of detected.worktrees) { - knownIds.add(worktree.id) + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } } } else { for (const worktree of state.worktreesByRepo[repoId] ?? []) { - knownIds.add(worktree.id) + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } } } - if (!state.hasHydratedWorktreePurge) { + if (!state.hasHydratedWorktreePurge && matchOptions.unhostedWorktreesMatchHost === true) { // Why (#1158): hydration can preserve tab keys before worktree metadata exists; // the first authoritative scan still needs to reap deleted session-only keys. for (const id of getHydratedSessionWorktreeIdsForRepo(state, repoId)) { @@ -370,13 +489,14 @@ function getKnownWorktreeIdsForPurge(state: AppState, repoId: string): string[] function getRemovedWorktreeIdsAfterAuthoritativeScan( state: AppState, repoId: string, - detected: DetectedWorktreeListResult + detected: DetectedWorktreeListResult, + hostId: ExecutionHostId ): string[] { if (!detected.authoritative) { return [] } const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) - return getKnownWorktreeIdsForPurge(state, repoId).filter((id) => !detectedIds.has(id)) + return getKnownWorktreeIdsForPurge(state, repoId, hostId).filter((id) => !detectedIds.has(id)) } function toLegacyDetectedWorktreeResult( @@ -595,30 +715,41 @@ function replaceWorktreeInRepoLists( } } -function settingsForRepoOwner(state: Pick, repoId: string) { - const repo = state.repos.find((entry) => entry.id === repoId) +function settingsForRepoOwner( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null +) { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) if (!repo) { return state.settings } + return settingsForKnownRepoOwner(state.settings, repo) +} + +function settingsForKnownRepoOwner( + settings: AppState['settings'], + repo: { connectionId?: string | null; executionHostId?: ExecutionHostId | null } +) { if (!repo.executionHostId && !repo.connectionId) { - return state.settings + return settings } const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) if (parsed?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) } - if (parsed?.kind === 'local' && state.settings?.activeRuntimeEnvironmentId) { - return { ...state.settings, activeRuntimeEnvironmentId: null } + if (parsed?.kind === 'local' && settings?.activeRuntimeEnvironmentId) { + return { ...settings, activeRuntimeEnvironmentId: null } } if (parsed?.kind !== 'ssh') { - return state.settings + return settings } // Why: SSH repos are owned by the desktop client/SSH provider, not the // currently focused runtime server. - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: null } + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) } @@ -641,14 +772,21 @@ function settingsForExecutionHostOwner( } function settingsForWorktreeOwner( - state: Pick, + state: Pick, worktreeId: string ) { const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) if (worktree?.hostId) { return settingsForExecutionHostOwner(state.settings, worktree.hostId) } - return settingsForRepoOwner(state, getRepoIdFromWorktreeId(worktreeId)) + const repoId = getRepoIdFromWorktreeId(worktreeId) + const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + (entry) => entry.id === worktreeId + ) + if (detected?.hostId) { + return settingsForExecutionHostOwner(state.settings, detected.hostId) + } + return settingsForRepoOwner(state, repoId) } async function listDetectedWorktreesForRepo( @@ -860,7 +998,7 @@ async function refreshRemoteWorktreeLineageBestEffort( } function getWorktreeHostId( - state: Pick, + state: Pick, worktreeId: string ): ExecutionHostId | null { const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) @@ -868,12 +1006,21 @@ function getWorktreeHostId( return worktree.hostId } const repoId = getRepoIdFromWorktreeId(worktreeId) - const repo = state.repos.find((entry) => entry.id === repoId) + const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + (entry) => entry.id === worktreeId + ) + if (detected?.hostId) { + return detected.hostId + } + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) return repo ? getRepoExecutionHostId(repo) : null } function mergeLineageForHost( - state: Pick, + state: Pick< + AppState, + 'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'worktreeLineageById' + >, hostId: ExecutionHostId, lineage: Record ): Record { @@ -887,7 +1034,14 @@ function mergeLineageForHost( } function mergeWorkspaceLineageForHost( - state: Pick, + state: Pick< + AppState, + | 'repos' + | 'settings' + | 'worktreesByRepo' + | 'detectedWorktreesByRepo' + | 'workspaceLineageByChildKey' + >, hostId: ExecutionHostId, lineage: Record ): Record { @@ -1552,24 +1706,58 @@ export const createWorktreeSlice: StateCreator fetchWorktrees: async (repoId, options) => { try { - const settings = settingsForRepoOwner(get(), repoId) + const ownerState = get() + const hostId = repoHostId(ownerState, repoId) + const settings = settingsForRepoOwner(ownerState, repoId, hostId) const detected = await listDetectedWorktreesForRepo(settings, repoId) if (options?.requireAuthoritative && !detected.authoritative) { return false } - const worktrees = toVisibleWorktrees(detected, repoHostId(get(), repoId)) + const worktrees = toVisibleWorktrees(detected, hostId) const current = get().worktreesByRepo[repoId] - if (areWorktreesEqual(current, worktrees)) { + const currentMatchOptions = worktreeHostMatchOptions(get(), repoId, hostId) + const currentForHost = (current ?? []).filter((worktree) => + worktreeMatchesHost(worktree, hostId, currentMatchOptions) + ) + if (areWorktreesEqual(currentForHost, worktrees)) { set((s) => { - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected) + const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( + s, + repoId, + detected, + hostId + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + matchOptions + ) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[repoId], + worktrees, + hostId, + matchOptions + ) + const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[repoId], mergedWorktrees) if ( - areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], detected) && + !worktreesChanged && + areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) && removedIds.length === 0 ) { return s } return { - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected }, + worktreesByRepo: { + ...s.worktreesByRepo, + [repoId]: mergedWorktrees + }, + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergedDetected + }, + ...(worktreesChanged ? { sortEpoch: s.sortEpoch + 1 } : {}), ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) @@ -1584,9 +1772,17 @@ export const createWorktreeSlice: StateCreator // badge then shows raw worktree IDs instead of display names, and click- // to-navigate silently fails because findWorktreeById returns undefined. // Keep the stale-but-correct data until the next successful refresh. - if (!detected.authoritative && worktrees.length === 0 && current && current.length > 0) { + if (!detected.authoritative && worktrees.length === 0 && currentForHost.length > 0) { set((s) => ({ - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected } + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + worktreeHostMatchOptions(s, repoId, hostId) + ) + } })) return false } @@ -1595,14 +1791,27 @@ export const createWorktreeSlice: StateCreator // Why: hidden worktrees are not in worktreesByRepo. Purge decisions // must diff against the previous authoritative detected list so hiding // does not delete state, and deleting a hidden worktree still does. - const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected) + const matchOptions = worktreeHostMatchOptions(s, repoId, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, repoId, detected, hostId) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[repoId], + worktrees, + hostId, + matchOptions + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + detected, + hostId, + matchOptions + ) return { // Why: active worktrees can change branches entirely from a terminal. // We refresh that live git identity into renderer state, but only bump // sortEpoch when git actually reports a different worktree payload. - worktreesByRepo: { ...s.worktreesByRepo, [repoId]: worktrees }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: detected }, + worktreesByRepo: { ...s.worktreesByRepo, [repoId]: mergedWorktrees }, + detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [repoId]: mergedDetected }, sortEpoch: s.sortEpoch + 1, ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } @@ -1622,7 +1831,41 @@ export const createWorktreeSlice: StateCreator // calls just need to refresh each repo's cached list. No need to // double-probe the IPC for the per-repo success signal. if (get().hasHydratedWorktreePurge) { - await mapReposForWorktreeRefresh(repos, (r) => get().fetchWorktrees(r.id)) + await mapReposForWorktreeRefresh(repos, async (r) => { + const hostId = getRepoExecutionHostId(r) + const settings = settingsForKnownRepoOwner(get().settings, r) + const detected = await listDetectedWorktreesForRepo(settings, r.id) + const worktrees = toVisibleWorktrees(detected, hostId) + set((s) => { + const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan(s, r.id, detected, hostId) + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[r.id], + worktrees, + hostId, + matchOptions + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + matchOptions + ) + if ( + areWorktreesEqual(s.worktreesByRepo[r.id], mergedWorktrees) && + areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[r.id], mergedDetected) && + removedIds.length === 0 + ) { + return s + } + return { + worktreesByRepo: { ...s.worktreesByRepo, [r.id]: mergedWorktrees }, + detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: mergedDetected }, + sortEpoch: s.sortEpoch + 1, + ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) + } + }) + }) return } @@ -1646,24 +1889,51 @@ export const createWorktreeSlice: StateCreator | { repoId: string; ok: false } > => { try { + const hostId = getRepoExecutionHostId(r) const detected = await listDetectedWorktreesForRepo( - settingsForRepoOwner(get(), r.id), + settingsForKnownRepoOwner(get().settings, r), r.id ) - const list = toVisibleWorktrees(detected, repoHostId(get(), r.id)) + const list = toVisibleWorktrees(detected, hostId) const current = get().worktreesByRepo[r.id] + const currentMatchOptions = worktreeHostMatchOptions(get(), r.id, hostId) + const currentForHost = (current ?? []).filter((worktree) => + worktreeMatchesHost(worktree, hostId, currentMatchOptions) + ) if ( - !areWorktreesEqual(current, list) && - !(list.length === 0 && current && current.length > 0 && !detected.authoritative) + !areWorktreesEqual(currentForHost, list) && + !(list.length === 0 && currentForHost.length > 0 && !detected.authoritative) ) { - set((s) => ({ - worktreesByRepo: { ...s.worktreesByRepo, [r.id]: list }, - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: detected }, - sortEpoch: s.sortEpoch + 1 - })) + set((s) => { + const matchOptions = worktreeHostMatchOptions(s, r.id, hostId) + return { + worktreesByRepo: { + ...s.worktreesByRepo, + [r.id]: mergeWorktreesForHost(s.worktreesByRepo[r.id], list, hostId, matchOptions) + }, + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [r.id]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + matchOptions + ) + }, + sortEpoch: s.sortEpoch + 1 + } + }) } else { set((s) => ({ - detectedWorktreesByRepo: { ...s.detectedWorktreesByRepo, [r.id]: detected } + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [r.id]: mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[r.id], + detected, + hostId, + worktreeHostMatchOptions(s, r.id, hostId) + ) + } })) } return { repoId: r.id, ok: detected.authoritative, detected } diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 83db7e37efd..e69917db03e 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -456,6 +456,42 @@ describe('keybindings', () => { ) }) + it('keeps the sleeping-workspaces toggle unassigned until users customize it', () => { + const binding = { + key: 's', + code: 'KeyS', + control: true, + meta: false, + alt: true, + shift: false + } + + // Ships unbound on every platform (issue #5209): assign-it-yourself. + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'darwin')).toEqual( + [] + ) + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'linux')).toEqual( + [] + ) + expect(getEffectiveKeybindingsForAction('sidebar.sleepingWorkspaces.toggle', 'win32')).toEqual( + [] + ) + expect(keybindingMatchesAction('sidebar.sleepingWorkspaces.toggle', binding, 'linux')).toBe( + false + ) + expect( + keybindingMatchesAction('sidebar.sleepingWorkspaces.toggle', binding, 'linux', { + 'sidebar.sleepingWorkspaces.toggle': ['Mod+Alt+S'] + }) + ).toBe(true) + + const definition = getKeybindingDefinition('sidebar.sleepingWorkspaces.toggle') + expect(definition?.title).toBe('Toggle Sleeping Workspaces') + expect(definition?.searchKeywords).toEqual( + expect.arrayContaining(['sleeping', 'workspaces', 'filter']) + ) + }) + it('defines floating workspace panel action metadata', () => { const actionIds = [ 'floatingWorkspace.maximize' as KeybindingActionId, diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 57a8b775267..6d4c3e64fb7 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -48,6 +48,7 @@ export type KeybindingActionId = | 'sidebar.sourceControl.toggle' | 'sidebar.checks.toggle' | 'sidebar.ports.toggle' + | 'sidebar.sleepingWorkspaces.toggle' | 'sidebar.focusWorktreeList' | 'floatingTerminal.toggle' | 'floatingWorkspace.maximize' @@ -393,6 +394,26 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ win32: [] } }, + { + id: 'sidebar.sleepingWorkspaces.toggle', + title: 'Toggle Sleeping Workspaces', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'sidebar', + 'sleeping', + 'asleep', + 'workspaces', + 'worktree', + 'filter', + 'show', + 'hide' + ], + // Why: ship unbound — issue #5209 asks to "assign a shortcut", so we avoid + // claiming a cross-platform chord and let users bind it in Settings. + defaultBindings: platformBindings([]) + }, { id: 'sidebar.focusWorktreeList', title: 'Focus worktree list', diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index aab99c6e255..51f4bda546a 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -526,6 +526,9 @@ export type RuntimeWorktreePsSummary = { parentWorktreeId: string | null childWorktreeIds: string[] displayName: string + workspaceStatus: string + sortOrder: number + manualOrder?: number linkedIssue: number | null linkedPR: { number: number; state: string } | null linkedLinearIssue: string | null diff --git a/src/shared/types.ts b/src/shared/types.ts index 20db64abc6c..c56b9af4acc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1125,6 +1125,11 @@ export type PRRefreshOutcome = export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr' +export type GitHubPRRefreshEnqueueResult = + | { kind: 'queued' } + | { kind: 'skipped'; skippedReason: 'validation-denied' | 'validation-backoff' } + | { kind: 'fallback' } + export type GitHubPRRefreshAlias = { cacheKey: string repoId?: string diff --git a/tests/e2e/combined-diff-scroll-restore.spec.ts b/tests/e2e/combined-diff-scroll-restore.spec.ts new file mode 100644 index 00000000000..191f7810f4b --- /dev/null +++ b/tests/e2e/combined-diff-scroll-restore.spec.ts @@ -0,0 +1,403 @@ +import { execFileSync } from 'child_process' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'fs' +import os from 'os' +import path from 'path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +type CombinedDiffScrollRepo = { + repoPath: string +} + +type ViewportAnchor = { + key: string + index: number + top: number + bottom: number + scrollTop: number + scrollHeight: number + clientHeight: number +} + +type ScrollProbeSample = { + scrollHeight: number + scrollTop: number +} + +const FILE_COUNT = 18 +const ADDED_LINES_PER_FILE = 180 + +function runGit(repoPath: string, args: string[]): void { + execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) +} + +function buildBaseFile(fileIndex: number): string { + return `${Array.from( + { length: 20 }, + (_, lineIndex) => `export const base_${fileIndex}_${lineIndex} = ${lineIndex}` + ).join('\n')}\n` +} + +function buildModifiedFile(fileIndex: number): string { + const added = Array.from( + { length: ADDED_LINES_PER_FILE }, + (_, lineIndex) => `export const changed_${fileIndex}_${lineIndex} = ${fileIndex + lineIndex}` + ).join('\n') + return `${buildBaseFile(fileIndex)}${added}\n` +} + +function createCombinedDiffScrollRepo(): CombinedDiffScrollRepo { + const repoPath = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-combined-diff-scroll-'))) + runGit(repoPath, ['init']) + runGit(repoPath, ['config', 'user.email', 'e2e@test.local']) + runGit(repoPath, ['config', 'user.name', 'E2E Test']) + + const srcDir = path.join(repoPath, 'src') + mkdirSync(srcDir, { recursive: true }) + for (let index = 0; index < FILE_COUNT; index += 1) { + writeFileSync( + path.join(srcDir, `scroll-${String(index).padStart(2, '0')}.ts`), + buildBaseFile(index) + ) + } + runGit(repoPath, ['add', '-A']) + runGit(repoPath, ['commit', '-m', 'Initial combined diff scroll fixture']) + + for (let index = 0; index < FILE_COUNT; index += 1) { + writeFileSync( + path.join(srcDir, `scroll-${String(index).padStart(2, '0')}.ts`), + buildModifiedFile(index) + ) + } + + return { repoPath } +} + +async function addAndActivateRepo(page: Page, repoPath: string): Promise { + const repoId = await page.evaluate(async (pathToRepo: string) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const addedRepo = await store.getState().addRepoPath(pathToRepo) + if (!addedRepo) { + throw new Error(`isolated combined-diff repo not found: ${pathToRepo}`) + } + return addedRepo.id + }, repoPath) + + await expect + .poll( + () => + page.evaluate(async (targetRepoId: string) => { + const store = window.__store + if (!store) { + return 0 + } + await store.getState().fetchWorktrees(targetRepoId) + return store.getState().worktreesByRepo[targetRepoId]?.length ?? 0 + }, repoId), + { + timeout: 30_000, + message: 'isolated combined-diff worktree did not load' + } + ) + .toBeGreaterThan(0) + + return page.evaluate( + ({ targetRepoId, pathToRepo }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + + const state = store.getState() + const worktrees = state.worktreesByRepo[targetRepoId] ?? [] + const worktree = worktrees.find((entry) => entry.path === pathToRepo) ?? worktrees[0] + if (!worktree) { + throw new Error(`isolated combined-diff worktree not found: ${pathToRepo}`) + } + state.setActiveRepo(targetRepoId) + state.setActiveWorktree(worktree.id) + return worktree.id + }, + { targetRepoId: repoId, pathToRepo: repoPath } + ) +} + +async function openCombinedDiff(page: Page, worktreeId: string, repoPath: string): Promise { + return page.evaluate( + async ({ wId, pathToRepo }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const status = await window.api.git.status({ worktreePath: pathToRepo }) + const entries = status.entries.filter((entry) => entry.area === 'unstaged') + if (entries.length < 2) { + throw new Error(`expected multiple unstaged entries, received ${entries.length}`) + } + state.setGitStatus(wId, status) + state.openAllDiffs(wId, pathToRepo, undefined, 'unstaged', entries) + + const nextState = store.getState() + const activeGroupId = nextState.activeGroupIdByWorktree[wId] + const activeFileId = nextState.activeFileId + const tab = (nextState.unifiedTabsByWorktree[wId] ?? []).find( + (candidate) => candidate.groupId === activeGroupId && candidate.entityId === activeFileId + ) + if (!tab) { + throw new Error('combined diff tab was not created') + } + return tab.id + }, + { wId: worktreeId, pathToRepo: repoPath } + ) +} + +async function scrollCombinedDiffDeep(page: Page): Promise { + await page.evaluate(() => { + const container = document.querySelector('.combined-diff-scroll-container') + if (!container) { + throw new Error('combined diff scroll container not found') + } + const target = Math.min(7_000, Math.max(0, container.scrollHeight - container.clientHeight - 1)) + container.dispatchEvent( + new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: target }) + ) + container.scrollTop = target + container.dispatchEvent(new Event('scroll', { bubbles: true })) + }) +} + +async function readViewportAnchor(page: Page): Promise { + return page.evaluate(() => { + const container = document.querySelector('.combined-diff-scroll-container') + if (!container) { + return null + } + const containerRect = container.getBoundingClientRect() + const visibleRows = Array.from( + container.querySelectorAll('[data-combined-diff-section-row]') + ) + .map((row) => { + const rect = row.getBoundingClientRect() + const key = row.dataset.combinedDiffSectionKey + const index = Number(row.dataset.index) + if ( + !key || + !Number.isFinite(index) || + rect.height <= 0 || + rect.bottom <= containerRect.top || + rect.top >= containerRect.bottom + ) { + return null + } + return { + key, + index, + top: rect.top - containerRect.top, + bottom: rect.bottom - containerRect.top, + scrollTop: container.scrollTop, + scrollHeight: container.scrollHeight, + clientHeight: container.clientHeight + } + }) + .filter((row): row is ViewportAnchor => row !== null) + .sort((a, b) => a.top - b.top) + return visibleRows[0] ?? null + }) +} + +async function waitForStableViewportAnchor(page: Page): Promise { + const startedAt = Date.now() + let lastSignature = '' + let stableSamples = 0 + let lastAnchor: ViewportAnchor | null = null + + while (Date.now() - startedAt < 15_000) { + const anchor = await readViewportAnchor(page) + if (anchor) { + const signature = `${anchor.key}:${Math.round(anchor.top)}:${Math.round( + anchor.bottom + )}:${Math.round(anchor.scrollHeight)}` + if (signature === lastSignature) { + stableSamples += 1 + if (stableSamples >= 2) { + return anchor + } + } else { + lastSignature = signature + stableSamples = 0 + } + lastAnchor = anchor + } + await page.waitForTimeout(100) + } + + throw new Error(`combined diff viewport anchor did not settle: ${JSON.stringify(lastAnchor)}`) +} + +async function startCombinedDiffScrollProbe(page: Page): Promise { + await page.evaluate(() => { + type CombinedDiffScrollProbe = { + samples: ScrollProbeSample[] + stop: () => void + } + const targetWindow = window as typeof window & { + __combinedDiffScrollProbe?: CombinedDiffScrollProbe + } + targetWindow.__combinedDiffScrollProbe?.stop() + + const container = document.querySelector('.combined-diff-scroll-container') + if (!container) { + throw new Error('combined diff scroll container not found') + } + + const samples: ScrollProbeSample[] = [] + const record = (): void => { + samples.push({ + scrollHeight: container.scrollHeight, + scrollTop: container.scrollTop + }) + } + container.addEventListener('scroll', record, { passive: true }) + record() + targetWindow.__combinedDiffScrollProbe = { + samples, + stop: () => container.removeEventListener('scroll', record) + } + }) +} + +async function stopCombinedDiffScrollProbe(page: Page): Promise { + return page.evaluate(() => { + type CombinedDiffScrollProbe = { + samples: ScrollProbeSample[] + stop: () => void + } + const targetWindow = window as typeof window & { + __combinedDiffScrollProbe?: CombinedDiffScrollProbe + } + const probe = targetWindow.__combinedDiffScrollProbe + if (!probe) { + return [] + } + probe.stop() + delete targetWindow.__combinedDiffScrollProbe + return probe.samples + }) +} + +async function wheelCombinedDiffDown(page: Page): Promise { + const container = page.locator('.combined-diff-scroll-container') + const box = await container.boundingBox() + if (!box) { + throw new Error('combined diff scroll container bounds not found') + } + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await startCombinedDiffScrollProbe(page) + for (let index = 0; index < 12; index += 1) { + await page.mouse.wheel(0, 520) + await page.waitForTimeout(35) + } + await page.waitForTimeout(400) + return stopCombinedDiffScrollProbe(page) +} + +function getLargestBackwardScrollJump(samples: readonly ScrollProbeSample[]): number { + let largestBackwardJump = 0 + for (let index = 1; index < samples.length; index += 1) { + largestBackwardJump = Math.max( + largestBackwardJump, + samples[index - 1].scrollTop - samples[index].scrollTop + ) + } + return largestBackwardJump +} + +async function clickVisibleDiffLine(page: Page): Promise { + const linePoint = await page.evaluate(() => { + const container = document.querySelector('.combined-diff-scroll-container') + if (!container) { + throw new Error('combined diff scroll container not found') + } + const containerRect = container.getBoundingClientRect() + const visibleLine = Array.from( + container.querySelectorAll('.monaco-diff-editor .view-line') + ).find((line) => { + const rect = line.getBoundingClientRect() + return ( + rect.height > 0 && + rect.bottom > containerRect.top && + rect.top < containerRect.bottom && + rect.right > containerRect.left && + rect.left < containerRect.right + ) + }) + if (!visibleLine) { + throw new Error('visible combined diff line not found') + } + const rect = visibleLine.getBoundingClientRect() + return { + x: rect.left + Math.min(12, Math.max(1, rect.width / 2)), + y: rect.top + rect.height / 2 + } + }) + + await page.mouse.click(linePoint.x, linePoint.y) +} + +test.describe('Combined diff scroll restore', () => { + test.describe.configure({ mode: 'serial' }) + test.use({ seedTestRepo: false }) + + test('keeps the visible section anchored after switching tabs', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const fixture = createCombinedDiffScrollRepo() + + try { + const worktreeId = await addAndActivateRepo(orcaPage, fixture.repoPath) + const diffTabId = await openCombinedDiff(orcaPage, worktreeId, fixture.repoPath) + await expect(orcaPage.locator('.combined-diff-scroll-container')).toBeVisible() + await expect(orcaPage.getByText(`${FILE_COUNT} changed files`)).toBeVisible() + + await scrollCombinedDiffDeep(orcaPage) + await waitForStableViewportAnchor(orcaPage) + const activeScrollSamples = await wheelCombinedDiffDown(orcaPage) + expect(activeScrollSamples.length).toBeGreaterThan(2) + expect(getLargestBackwardScrollJump(activeScrollSamples)).toBeLessThan(120) + + const beforeSwitch = await waitForStableViewportAnchor(orcaPage) + expect(beforeSwitch.index).toBeGreaterThan(0) + + await orcaPage.evaluate((wId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.getState().createTab(wId) + }, worktreeId) + await expect(orcaPage.locator('.combined-diff-scroll-container')).toHaveCount(0) + + await orcaPage.locator(`[data-tab-id="${diffTabId}"]`).click({ force: true }) + await expect(orcaPage.locator('.combined-diff-scroll-container')).toBeVisible() + const afterSwitch = await waitForStableViewportAnchor(orcaPage) + + expect(afterSwitch.key).toBe(beforeSwitch.key) + expect(Math.abs(afterSwitch.top - beforeSwitch.top)).toBeLessThan(80) + + await clickVisibleDiffLine(orcaPage) + const afterLineClick = await waitForStableViewportAnchor(orcaPage) + + expect(afterLineClick.key).toBe(afterSwitch.key) + expect(Math.abs(afterLineClick.top - afterSwitch.top)).toBeLessThan(80) + } finally { + rmSync(fixture.repoPath, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/e2e/folder-setup-shallow-priority.spec.ts b/tests/e2e/folder-setup-shallow-priority.spec.ts index 96fe04580a1..b970a29ba8f 100644 --- a/tests/e2e/folder-setup-shallow-priority.spec.ts +++ b/tests/e2e/folder-setup-shallow-priority.spec.ts @@ -8,7 +8,7 @@ import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' const tempRoots: string[] = [] -const IMPORT_AS_MONOREPO_BUTTON_NAME = 'Yes, import as monorepo' +const IMPORT_AS_GROUP_BUTTON_NAME = 'Yes, import as group' function initializeGitRepo(repoPath: string): void { mkdirSync(repoPath, { recursive: true }) @@ -133,11 +133,11 @@ async function installCancellableNestedScanMock( }, scan) } -function getImportAsMonorepoButton(importDialog: Locator): Locator { +function getImportAsGroupButton(importDialog: Locator): Locator { // Why: this test should fail on import-dialog copy drift instead of falling // back to a retired accessible label. return importDialog.getByRole('button', { - name: IMPORT_AS_MONOREPO_BUTTON_NAME, + name: IMPORT_AS_GROUP_BUTTON_NAME, exact: true }) } @@ -177,7 +177,7 @@ test('prioritizes shallow sibling repositories in a bounded nested scan', async .filter({ hasText: 'z-web-client' }) .locator('input[type="checkbox"]') .check() - await getImportAsMonorepoButton(importDialog).click() + await getImportAsGroupButton(importDialog).click() await expect .poll( @@ -257,13 +257,13 @@ test('can stop a nested repo scan and import repositories found so far', async ( name: /Import repositories from folder/i }) await expect(importDialog.getByText(/Scanning\.\.\.\s*Found 1 repository in/)).toBeVisible() - await expect(getImportAsMonorepoButton(importDialog)).toBeDisabled() + await expect(getImportAsGroupButton(importDialog)).toBeDisabled() await importDialog.getByRole('button', { name: /Stop scan/i }).click() await expect(importDialog.getByText('Scan stopped early.')).toBeVisible() await expect(importDialog.getByText(/Found 1 repository in/)).toBeVisible() - await expect(getImportAsMonorepoButton(importDialog)).toBeEnabled() + await expect(getImportAsGroupButton(importDialog)).toBeEnabled() - await getImportAsMonorepoButton(importDialog).click() + await getImportAsGroupButton(importDialog).click() await expect .poll( diff --git a/tests/e2e/folder-setup.spec.ts b/tests/e2e/folder-setup.spec.ts index 107ce1088f2..cbabf8e0bbe 100644 --- a/tests/e2e/folder-setup.spec.ts +++ b/tests/e2e/folder-setup.spec.ts @@ -7,7 +7,7 @@ import { test, expect } from './helpers/orca-app' import { waitForSessionReady } from './helpers/store' import type { ElectronApplication, Locator } from '@stablyai/playwright-test' -const IMPORT_AS_MONOREPO_BUTTON_NAME = 'Yes, import as monorepo' +const IMPORT_AS_GROUP_BUTTON_NAME = 'Yes, import as group' const tempRoots: string[] = [] @@ -95,11 +95,11 @@ async function chooseFolderInNativeDialog( }, folderPath) } -function getImportAsMonorepoButton(importDialog: Locator): Locator { +function getImportAsGroupButton(importDialog: Locator): Locator { // Why: the current accessible action is intentional; accepting retired // labels would hide deterministic dialog copy drift. return importDialog.getByRole('button', { - name: IMPORT_AS_MONOREPO_BUTTON_NAME, + name: IMPORT_AS_GROUP_BUTTON_NAME, exact: true }) } @@ -129,8 +129,8 @@ test.describe('Folder setup', () => { ).toBeVisible() await expect(importDialog.getByText('api-service', { exact: true }).first()).toBeVisible() await expect(importDialog.getByText('web-client', { exact: true }).first()).toBeVisible() - await expect(getImportAsMonorepoButton(importDialog)).toBeEnabled() - await getImportAsMonorepoButton(importDialog).click() + await expect(getImportAsGroupButton(importDialog)).toBeEnabled() + await getImportAsGroupButton(importDialog).click() await expect .poll( @@ -217,7 +217,7 @@ test.describe('Folder setup', () => { .locator('input[type="checkbox"]') .check() } - await getImportAsMonorepoButton(importDialog).click() + await getImportAsGroupButton(importDialog).click() await expect .poll( diff --git a/tests/e2e/terminal-paste-ownership.spec.ts b/tests/e2e/terminal-paste-ownership.spec.ts index 998aa844147..3e95b02f123 100644 --- a/tests/e2e/terminal-paste-ownership.spec.ts +++ b/tests/e2e/terminal-paste-ownership.spec.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { rmSync, writeFileSync } from 'node:fs' import path from 'node:path' -import type { Page } from '@stablyai/playwright-test' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { focusActiveTerminalInput, @@ -112,6 +112,62 @@ async function rightClickActiveTerminalSurface(page: Page): Promise { await page.mouse.click(point.x, point.y, { button: 'right' }) } +async function installClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { + await app.evaluate(({ BrowserWindow, ipcMain }) => { + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + const global = globalThis as unknown as { + __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler + } + const invokeHandlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + const handler = invokeHandlers?.get('clipboard:readText') + if (!invokeHandlers || !handler || global.__orcaOriginalClipboardReadTextHandler) { + return + } + global.__orcaOriginalClipboardReadTextHandler = handler + invokeHandlers.set('clipboard:readText', async (event, ...args) => { + const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()) + await Promise.all( + windows.map((window) => + window.webContents + .executeJavaScript( + ` + (document.activeElement && document.activeElement.blur && document.activeElement.blur()); + document.body.tabIndex = -1; + document.body.focus(); + ` + ) + .catch(() => undefined) + ) + ) + // Why: reproduce the focus churn window before the async clipboard read resolves. + await new Promise((resolve) => setTimeout(resolve, 0)) + return global.__orcaOriginalClipboardReadTextHandler!(event, ...args) + }) + }) +} + +async function restoreClipboardReadTerminalBlurRepro(app: ElectronApplication): Promise { + await app.evaluate(({ ipcMain }) => { + type ClipboardReadHandler = (event: unknown, ...args: unknown[]) => Promise | unknown + const global = globalThis as unknown as { + __orcaOriginalClipboardReadTextHandler?: ClipboardReadHandler + } + const invokeHandlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + if (invokeHandlers && global.__orcaOriginalClipboardReadTextHandler) { + invokeHandlers.set('clipboard:readText', global.__orcaOriginalClipboardReadTextHandler) + delete global.__orcaOriginalClipboardReadTextHandler + } + }) +} + async function openTerminalContextMenu(page: Page): Promise { const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows')) const isMac = await page.evaluate(() => navigator.userAgent.includes('Mac')) @@ -171,6 +227,49 @@ test.describe('terminal paste ownership', () => { } }) + test('keyboard paste survives transient terminal blur during clipboard read', async ({ + electronApp, + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await installTerminalPtyWriteSpy(electronApp) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-paste-blur-${runId}.mjs`) + writeFileSync(scriptPath, pasteEchoScript(runId)) + let scriptStarted = false + + try { + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + scriptStarted = true + await waitForTerminalOutput(orcaPage, `PASTE_READY_${runId}`, 10_000) + + const payload = `ORCA_E2E_TRANSIENT_BLUR_PASTE_${runId}` + const encodedPayload = Buffer.from(payload, 'utf8').toString('base64') + await clearTerminalPtyWriteLog(electronApp) + await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), payload) + await focusActiveTerminalInput(orcaPage) + await installClipboardReadTerminalBlurRepro(electronApp) + + await orcaPage.keyboard.press(keyboardPasteChords()[0]) + await waitForTerminalOutput(orcaPage, encodedPayload, 10_000, 12_000) + + const writes = (await readTerminalPtyWrites(electronApp)).join('') + expect(countOccurrences(writes, payload), 'transient blur PTY write count').toBe(1) + } finally { + await restoreClipboardReadTerminalBlurRepro(electronApp).catch(() => undefined) + if (scriptStarted) { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + } + rmSync(scriptPath, { force: true }) + } + }) + test('terminal context-menu Paste sends clipboard text exactly once', async ({ electronApp, orcaPage,