test: remove low-value sidebar tests (#38523)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-11-05 04:03:28 -08:00
committed by GitHub
co-authored by OrcaWin
parent 87788aeae5
commit 18eef71685
22 changed files with 7 additions and 8001 deletions
-4
View File
@@ -829,7 +829,6 @@
13 test-no-wall-clock:src/main/startup/windows-install-dir-acl-recovery.test.ts
3 test-no-wall-clock:src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx
3 test-no-wall-clock:src/renderer/src/components/native-chat/NativeChatMessageList.turn-indicator.test.tsx
13 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.pr-display.test.tsx
14 test-no-real-time-wait:src/main/ipc/worktrees/creation/worktree-base-directory-poller.test.ts
14 test-no-wall-clock:src/wsl-guest/agent-hook-integration.test.ts
15 test-no-wall-clock:src/main/github/pr-refresh-coordinator-alias-coalescing.test.ts
@@ -1264,7 +1263,6 @@
4 test-no-wall-clock:src/main/ssh/orcad-exited-own-fence-mutation-race.test.ts
4 test-no-wall-clock:src/main/ssh/ssh-remote-commands.test.ts
4 test-no-wall-clock:src/main/updater/lifecycle/serve-supervisor-install-park.test.ts
4 test-no-wall-clock:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx
4 test-no-wall-clock:src/renderer/src/components/task-page/jira/task-page-jira-cache-selectors.test.ts
4 test-no-wall-clock:tests/e2e/cross-version-wire/release-checkout.unit.test.ts
4 test-timeout-needs-reason:src/main/claude/claude-agent-sdk-exit-proof.test.ts
@@ -1308,7 +1306,6 @@
5 test-timeout-needs-reason:src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts
5 test-timeout-needs-reason:src/main/pty/process-retirement/node-pty-master-fd-retirement.test.ts
5 test-timeout-needs-reason:src/main/runtime/terminal/wait/tui-idle-hook-lane-runtime.test.ts
5 test-timeout-needs-reason:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx
5 test-timeout-needs-reason:tests/e2e/browser-agent-input-focus-isolation.spec.ts
5 test-timeout-needs-reason:tests/e2e/persisted-session-production-upgrade.spec.ts
5 test-timeout-needs-reason:tests/e2e/ssh-orcad-auto-convert.spec.ts
@@ -1351,7 +1348,6 @@
7 test-no-wall-clock:src/renderer/src/store/slices/jira.test.ts
7 test-no-wall-clock:src/shared/__tests__/process-host/windows-tree-kill-late-taskkill.win32.test.ts
7 test-timeout-needs-reason:mobile/src/tasks/worktree-create-retry.test.ts
7 test-timeout-needs-reason:src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.pr-display.test.tsx
8 test-no-in-test-import-actual:src/main/daemon/endpoint/publication/testing/daemon-endpoint-publish.test.ts
8 test-no-real-time-wait:tests/e2e/terminal-column-desync-repro.spec.ts
8 test-no-wall-clock:src/main/ai-vault-search/session-search-indexer.test.ts
@@ -7826,13 +7826,7 @@
"src/renderer/src/components/sidebar/sidebar-feedback/SidebarFeedbackDialog.test.tsx": 2529,
"src/renderer/src/components/sidebar/StalledTerminalDaemonBanner.test.tsx": 320,
"src/renderer/src/components/sidebar/workspace-board/viewport/WorkspaceKanbanLaneGrid.test.tsx": 360,
"src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.affiliate-list-mode.test.tsx": 2513,
"src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.compact-hover.test.tsx": 3316,
"src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.pr-display.test.tsx": 2817,
"src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.quick-actions.test.tsx": 3258,
"src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.single-reference-details.test.tsx": 2032,
"src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCard.ssh-reconnect-prompt.test.tsx": 2674,
"src/renderer/src/components/sidebar/workspace-card/surface/WorktreeCardStatusSlot.test.tsx": 2593,
"src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenu.closed-menu-work.test.tsx": 1753,
"src/renderer/src/components/sidebar/workspace-actions/context-menu/WorktreeContextMenu.test.ts": 299,
"src/renderer/src/components/sidebar/worktree-list/listing/WorktreeList.card-memo-stability.test.tsx": 2053,
@@ -7857,7 +7851,6 @@
"src/renderer/src/components/sidebar/workspace-actions/deletion/delete-worktree-parallel-flow.test.ts": 938,
"src/renderer/src/components/sidebar/workspace-card/review/folder-workspace-card-pr-display.test.ts": 377,
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-expiry.test.tsx": 2558,
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-path-status.test.tsx": 2113,
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit-host-platform.test.ts": 2264,
"src/renderer/src/components/sidebar/workspace-creation/folder/folder-workspace-composer-submit.test.ts": 2665,
"src/renderer/src/components/sidebar/worktree-list/drag/host-header-drag.test.tsx": 281,
@@ -7871,11 +7864,9 @@
"src/renderer/src/components/sidebar/worktree-list/grouping/pinned-section-worktrees.test.ts": 446,
"src/renderer/src/components/sidebar/workspace-actions/deletion/preserved-branch-batch-toast.test.tsx": 627,
"src/renderer/src/components/sidebar/worktree-list/drag/project-group-header-drop.test.ts": 198,
"src/renderer/src/components/sidebar/worktree-list/drag/project-header-drop.test.ts": 206,
"src/renderer/src/components/sidebar/project-move-pause-follower.test.ts": 938,
"src/renderer/src/components/sidebar/host-path-browser/remote-file-browser-entry-actions.test.ts": 289,
"src/renderer/src/components/sidebar/worktree-list/listing/rendered-sidebar-worktree-order.test.ts": 1873,
"src/renderer/src/components/sidebar/worktree-list/controls/sidebar-host-options.test.ts": 1810,
"src/renderer/src/components/sidebar/worktree-list/parity/sidebar-list-parity.test.tsx": 7474,
"src/renderer/src/components/sidebar/sidebar-live-width.test.ts": 177,
"src/renderer/src/components/sidebar/project-admission/drop/sidebar-project-drop.test.ts": 227,
@@ -7921,8 +7912,6 @@
"src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-host-setups.test.ts": 280,
"src/renderer/src/components/sidebar/worktree-list/grouping/worktree-list-groups-project-order.test.ts": 1861,
"src/renderer/src/components/sidebar/worktree-list/drag/pointer-flush.test.ts": 517,
"src/renderer/src/components/sidebar/worktree-list/drag/status-drop-twin-host.test.ts": 2078,
"src/renderer/src/components/sidebar/worktree-list/drag/twin-reorder.test.ts": 1831,
"src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.folder-workspace-lanes.test.ts": 271,
"src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.test.ts": 234,
"src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts": 248,
@@ -7955,8 +7944,6 @@
"src/renderer/src/components/sidebar/worktree-list/drag/worktree-manual-order-store-write.test.ts": 2144,
"src/renderer/src/components/sidebar/worktree-list/drag/worktree-manual-order.test.ts": 332,
"src/renderer/src/components/sidebar/workspace-actions/parenting/worktree-parent-eligibility.test.ts": 68,
"src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drag-autoscroll.test.ts": 60,
"src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drag-geometry.test.ts": 86,
"src/renderer/src/components/sidebar/worktree-list/drag/worktree-sidebar-drop-preview.test.ts": 80,
"src/renderer/src/components/sidebar/worktree-list/listing/worktree-sort-label-ordering.test.ts": 114,
"src/renderer/src/components/sidebar/wsl-host-offer-move.test.ts": 227,
@@ -1,324 +0,0 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
const openModal = vi.fn()
const setRenamingWorktreeId = vi.fn()
const updateWorktreeMeta = vi.fn()
const testDoubles = vi.hoisted(() => ({
activateWorktreeFromSidebar: vi.fn()
}))
const sleepMocks = vi.hoisted(() => ({
sleeping: false
}))
let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'comment']
let settings: Partial<GlobalSettings> | null = null
vi.mock('@/store', () => {
const getState = () => ({
browserTabsByWorktree: {},
createBrowserTab: vi.fn(),
deleteFolderWorkspace: vi.fn(),
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch: vi.fn(),
fetchIssue: vi.fn(),
fetchLinearIssue: vi.fn(),
gitConflictOperationByWorktree: {},
hostedReviewCache: {},
issueCache: {},
linearIssueCache: {},
openModal,
openTaskPage: vi.fn(),
projectGroups: [],
ptyIdsByTabId: {},
remoteBranchConflictByWorktreeId: {},
renamingWorktreeId: null,
setActiveWorktree: vi.fn(),
setRemoteBrowserPageHandle: vi.fn(),
setRenamingWorktreeId,
settings,
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
tabsByWorktree: {},
unifiedTabsByWorktree: {},
updateWorktreeMeta,
workspacePortScan: null,
worktreeCardProperties
})
return {
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), {
getState,
subscribe: () => () => {}
})
}
})
vi.mock('@/components/ui/hover-card', () => ({
HoverCard: ({ children }: { children: ReactNode }) => <>{children}</>,
HoverCardContent: ({ children }: { children: ReactNode }) => <>{children}</>,
HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/lib/sidebar-worktree-activation', () => ({
activateWorktreeFromSidebar: testDoubles.activateWorktreeFromSidebar
}))
vi.mock('../activity/use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => 'idle'
}))
vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({
useIsSleepingWorktree: () => sleepMocks.sleeping
}))
vi.mock('../activity/CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownStartedAt: () => null
}))
vi.mock('../activity/WorktreeCardAgents', () => ({
default: () => <div data-testid="inline-agents" />
}))
vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => (
<div data-testid="context-menu-wrapper">{children}</div>
),
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope',
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu'
}))
vi.mock('../identity/WorktreeTitleInlineRename', () => ({
WorktreeTitleInlineRename: ({
disabled,
displayName
}: {
disabled?: boolean
displayName: string
}) => (
<span data-testid="inline-rename" data-disabled={disabled ? 'true' : 'false'}>
{displayName}
</span>
)
}))
import WorktreeCard from './WorktreeCard'
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#999999',
addedAt: 1
}
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo-1::/repo/worktrees/affiliate',
repoId: 'repo-1',
path: '/repo/worktrees/affiliate',
displayName: 'Affiliate child',
branch: 'refs/heads/affiliate-child',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: 'read only',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
describe('WorktreeCard affiliate list mode', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
vi.clearAllMocks()
worktreeCardProperties = ['status', 'comment']
settings = null
sleepMocks.sleeping = false
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
})
it('keeps the card visual surface but disables mutating list interactions', () => {
act(() => {
root.render(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
const surface = container.querySelector<HTMLElement>('[data-worktree-card-surface="true"]')
const parentContent = container.querySelector<HTMLElement>(
'[data-worktree-card-parent-content=""]'
)
expect(surface).not.toBeNull()
expect(parentContent?.className).toContain('gap-0.5')
expect(parentContent?.className).not.toMatch(/\bpl-/)
const statusSlot = container.querySelector<HTMLElement>('[data-worktree-card-status-slot]')
expect(statusSlot?.className).toContain('px-1')
expect(container.querySelector('[data-testid="context-menu-wrapper"]')).toBeNull()
expect(surface?.getAttribute('draggable')).toBe('false')
expect(
container.querySelector('[data-testid="inline-rename"]')?.getAttribute('data-disabled')
).toBe('true')
act(() => {
surface?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
})
expect(openModal).not.toHaveBeenCalled()
expect(setRenamingWorktreeId).not.toHaveBeenCalled()
expect(updateWorktreeMeta).not.toHaveBeenCalled()
act(() => {
surface?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(testDoubles.activateWorktreeFromSidebar).toHaveBeenCalledWith(
'repo-1::/repo/worktrees/affiliate',
'local'
)
})
it('still shows inline agent details in affiliate list mode', () => {
worktreeCardProperties = ['status', 'inline-agents']
act(() => {
root.render(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
expect(container.querySelector('[data-testid="inline-agents"]')).not.toBeNull()
})
it('dims the full card surface for sleeping workspaces in new card style', () => {
sleepMocks.sleeping = true
settings = { experimentalNewWorktreeCardStyle: true }
act(() => {
root.render(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
expect(container.querySelector('[data-worktree-sleeping-dim=""]')).not.toBeNull()
const dim = container.querySelector('[data-worktree-sleeping-dim=""]')
expect(dim).not.toBeNull()
// Theme-token mixing must keep sleeping dim visible on light surfaces/custom tints without fading the whole backdrop.
expect(dim?.getAttribute('class') ?? '').not.toContain('opacity-')
expect(dim?.getAttribute('class') ?? '').not.toContain('backdrop-')
})
it('keeps awake cards at full opacity in new card style', () => {
settings = { experimentalNewWorktreeCardStyle: true }
act(() => {
root.render(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
expect(container.querySelector('[data-worktree-sleeping-dim=""]')).toBeNull()
})
it('keeps legacy sleeping cards undimmed', () => {
sleepMocks.sleeping = true
act(() => {
root.render(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
expect(container.querySelector('[data-worktree-sleeping-dim=""]')).toBeNull()
})
it('keeps the unread badge rendered on a dimmed sleeping card', () => {
sleepMocks.sleeping = true
settings = { experimentalNewWorktreeCardStyle: true }
act(() => {
root.render(
<WorktreeCard
worktree={makeWorktree({ isUnread: true })}
repo={makeRepo()}
isActive={false}
nativeDragEnabled
flushSurface
affiliateListMode
/>
)
})
expect(container.querySelector('[data-worktree-sleeping-dim=""]')).not.toBeNull()
expect(container.querySelector('[data-worktree-unread-alert=""]')).not.toBeNull()
})
})
@@ -1,842 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import React, { type ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { WorkspacePortScanResult } from '../../../../../../shared/workspace-ports'
const fetchHostedReviewForBranch = vi.fn()
const fetchIssue = vi.fn()
const fetchLinearIssue = vi.fn()
const openModal = vi.fn()
const openTaskPage = vi.fn()
const updateWorktreeMeta = vi.fn()
const recordFeatureInteraction = vi.fn()
const replaceWorkspacePortScans = vi.fn()
const setWorkspacePortScanRefreshing = vi.fn()
const cacheTimerMocks = vi.hoisted(() => ({
usePromptCacheCountdownStartedAt: vi.fn()
}))
let worktreeCardProperties: WorktreeCardProperty[] = ['status', 'ports']
let hostedReviewCache: Record<string, unknown> = {}
let issueCache: Record<string, unknown> = {}
let projectGroups: unknown[] = []
let workspacePortScan: { key: string; result: WorkspacePortScanResult } | null = null
let settings: Partial<GlobalSettings> | null = { compactWorktreeCards: true }
let agentActivityDisplayMode: 'compact' | 'full' | undefined
let mockInlineAgentRows: DashboardAgentRowData[] = []
vi.mock('@/store', () => {
const getState = () => ({
browserTabsByWorktree: {},
agentActivityDisplayMode,
createBrowserTab: vi.fn(),
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch,
fetchIssue,
fetchLinearIssue,
gitConflictOperationByWorktree: {},
hostedReviewCache,
issueCache,
linearIssueCache: {},
openModal,
openTaskPage,
projectGroups,
ptyIdsByTabId: {},
recordFeatureInteraction,
remoteBranchConflictByWorktreeId: {},
setRemoteBrowserPageHandle: vi.fn(),
replaceWorkspacePortScans,
setWorkspacePortScanRefreshing,
settings,
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
tabsByWorktree: {},
updateWorktreeMeta,
workspacePortScan,
worktreeCardProperties
})
return {
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), {
getState
})
}
})
vi.mock('@/components/ui/hover-card', () => ({
HoverCard: ({ children, openDelay }: { children: ReactNode; openDelay?: number }) => (
<div data-hover-open-delay={openDelay}>{children}</div>
),
HoverCardContent: ({ children }: { children: ReactNode }) => (
<div data-hover-card-content="">{children}</div>
),
HoverCardTrigger: ({ children }: { children: ReactNode }) =>
React.isValidElement(children) ? (
React.cloneElement(children as React.ReactElement<Record<string, unknown>>, {
'data-hover-card-trigger': ''
})
) : (
<>{children}</>
)
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/lib/sidebar-worktree-activation', () => ({
activateWorktreeFromSidebar: vi.fn()
}))
vi.mock('../activity/use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => 'active'
}))
vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({
useIsSleepingWorktree: () => false
}))
vi.mock('../activity/CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownStartedAt: cacheTimerMocks.usePromptCacheCountdownStartedAt
}))
vi.mock('../activity/useWorktreeAgentRows', () => ({
useWorktreeAgentRows: vi.fn(() => mockInlineAgentRows)
}))
vi.mock('../activity/WorktreeCardAgents', () => ({
default: ({ className, agents }: { className?: string; agents?: DashboardAgentRowData[] }) => (
<div className={className} data-agent-count={agents?.length ?? ''} data-worktree-agents="" />
)
}))
vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope',
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu'
}))
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#999999',
addedAt: 1
}
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo-1::/repo/worktrees/pr-456',
repoId: 'repo-1',
path: '/repo/worktrees/pr-456',
displayName: 'Fix stale GH PR',
branch: 'feature/local-branch',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
function makeHostedReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
return {
provider: 'github',
number: 456,
title: 'Fix stale GH PR',
state: 'open',
url: 'https://github.com/acme/orca/pull/456',
status: 'success',
updatedAt: '2026-05-17T00:00:00.000Z',
mergeable: 'MERGEABLE',
...overrides
}
}
function expectIdentityBodyIsHoverTrigger(markup: string): void {
const surfaceTag = markup.match(/<div[^>]*data-worktree-card-surface="true"[^>]*>/)?.[0]
const triggerTag = markup.match(/<div[^>]*data-worktree-card-hover-trigger=""[^>]*>/)?.[0]
expect(surfaceTag).toBeDefined()
expect(surfaceTag).not.toContain('data-hover-card-trigger=""')
expect(surfaceTag).not.toContain('group/worktree-card')
expect(triggerTag).toBeDefined()
expect(triggerTag).toContain('data-hover-card-trigger=""')
expect(triggerTag).toContain('group/worktree-card')
}
describe('WorktreeCard compact hover details', () => {
beforeEach(() => {
vi.clearAllMocks()
worktreeCardProperties = ['status', 'ports']
hostedReviewCache = {}
issueCache = {}
projectGroups = []
workspacePortScan = null
settings = { compactWorktreeCards: true }
agentActivityDisplayMode = undefined
mockInlineAgentRows = []
cacheTimerMocks.usePromptCacheCountdownStartedAt.mockReturnValue(null)
})
it('shows PR and live port details from the compact worktree card hover', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
const worktree = makeWorktree({ linkedPR: 456 })
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview(),
fetchedAt: Date.now()
}
}
workspacePortScan = {
key: 'repo-1',
result: {
platform: 'darwin',
scannedAt: 1,
ports: [
{
id: '127.0.0.1:58941:1234',
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port: 58941,
pid: 1234,
processName: 'node',
protocol: 'http',
kind: 'workspace',
owner: {
worktreeId: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName,
path: worktree.path,
confidence: 'cwd'
}
}
]
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('data-worktree-title-inline-rename=""')
expectIdentityBodyIsHoverTrigger(markup)
expect(markup).toContain('data-hover-open-delay="100"')
expect(markup).toContain('PR #456')
expect(markup).toContain('Fix stale GH PR')
expect(markup).toContain('Live Ports')
expect(markup).toContain('58941')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('aria-label="1 live port"')
}, 30_000)
it('shows hidden task, notes, and port details from the compact worktree card hover', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
const worktree = makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
linkedPR: 456,
comment: 'Reviewer handoff note'
})
workspacePortScan = {
key: 'repo-1',
result: {
platform: 'darwin',
scannedAt: 1,
ports: [
{
id: '127.0.0.1:58941:1234',
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port: 58941,
pid: 1234,
processName: 'node',
protocol: 'http',
kind: 'workspace',
owner: {
worktreeId: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName,
path: worktree.path,
confidence: 'cwd'
}
}
]
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('data-hover-open-delay="100"')
expectIdentityBodyIsHoverTrigger(markup)
expect(markup).toContain('Issue #123')
expect(markup).toContain('ENG-123</span>')
expect(markup).toContain('aria-label="Linear"')
expect(markup).toContain('Reviewer handoff note')
expect(markup).toContain('Live Ports')
expect(markup).toContain('58941')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
}, 30_000)
it('reads linked issue details from the local repo-owner cache while a runtime is focused', async () => {
settings = {
activeRuntimeEnvironmentId: 'env-1',
compactWorktreeCards: true,
experimentalNewWorktreeCardStyle: true
}
worktreeCardProperties = ['status']
issueCache = {
'repo-1::123': {
data: { number: 123, title: 'Local owner issue', state: 'open', url: null },
fetchedAt: Date.now()
},
'runtime:env-1::repo-1::123': {
data: { number: 123, title: 'Runtime fallback issue', state: 'open', url: null },
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedIssue: 123 })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Local owner issue')
expect(markup).not.toContain('Runtime fallback issue')
expect(markup).not.toContain('Loading issue')
}, 30_000)
it('shows selected task and note metadata on the compact card title row', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('Linked issue #123')
expect(markup).toContain('Linked Linear ENG-123')
expect(markup).toContain('Workspace notes')
}, 30_000)
it('keeps selected task and note metadata above the compact branch row', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch', 'issue', 'linear-issue', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
const issueIndex = markup.indexOf('Linked issue #123')
const branchRowIndex = markup.indexOf('data-worktree-card-meta-row=""')
expect(issueIndex).toBeGreaterThanOrEqual(0)
expect(branchRowIndex).toBeGreaterThanOrEqual(0)
expect(issueIndex).toBeLessThan(branchRowIndex)
expect(markup).toContain('Linked Linear ENG-123')
expect(markup).toContain('Workspace notes')
expect(markup).toContain('feature/local-branch')
}, 30_000)
it('keeps branch identity visible on detailed cards by default', async () => {
settings = { compactWorktreeCards: false }
worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment', 'ports']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Human title' })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('data-hover-open-delay="100"')
expect(markup).toContain('feature/local-branch')
expect(markup).toContain('Human title')
})
it('uses one identity hover even when detailed metadata icons are visible when new card style is on', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment', 'ports']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
linkedPR: 456,
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Workspace metadata')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expectIdentityBodyIsHoverTrigger(markup)
expect(markup.match(/data-hover-open-delay="100"/g)).toHaveLength(1)
expect(markup).toContain('Reviewer handoff note')
})
it('keeps long workspace and branch identity in hover details when the branch row is hidden', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
branch: 'bug-hold-to-talk-speech-to-text-option-no-longer-works',
displayName: '[Bug]: Hold-to-talk speech-to-text option no longer works',
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expectIdentityBodyIsHoverTrigger(markup)
expect(markup).toContain('[Bug]: Hold-to-talk speech-to-text option no longer works')
expect(markup).toContain('bug-hold-to-talk-speech-to-text-option-no-longer-works')
expect(markup).toContain('Reviewer handoff note')
})
it('repeats a long workspace title inside the identity hover when branch is already visible', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch', 'comment']
const longTitle =
'Investigate why the worktree hover card title disappears behind single-line truncation'
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: longTitle, comment: 'Reviewer handoff note' })}
repo={makeRepo()}
isActive={false}
/>
)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup.match(new RegExp(longTitle, 'g'))).toHaveLength(2)
expect(markup).toContain('feature/local-branch')
expect(markup).toContain('Reviewer handoff note')
})
it('uses identity hover for identity-only new card worktrees with branch row visible', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Readable identity only' })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('data-worktree-card-meta-row=""')
expectIdentityBodyIsHoverTrigger(markup)
expect(markup.match(/data-hover-open-delay="100"/g)).toHaveLength(1)
expect(markup.match(/Readable identity only/g)).toHaveLength(2)
expect(markup).toContain('feature/local-branch')
expect(markup).not.toContain('Workspace metadata')
expect(markup).not.toContain('Live Ports')
})
it('does not duplicate workspace identity when trimmed title equals branch', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: ' feature/local-branch ' })}
repo={makeRepo()}
isActive={false}
/>
)
expectIdentityBodyIsHoverTrigger(markup)
expect(markup.match(/feature\/local-branch/g)).toHaveLength(3)
})
it('keeps detailed metadata hover scoped to metadata icons by default', async () => {
settings = { compactWorktreeCards: false }
worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment', 'ports']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
linkedPR: 456,
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('data-worktree-card-meta-row=""')
const surfaceTag = markup.match(/<div[^>]*data-worktree-card-surface="true"[^>]*>/)?.[0]
expect(surfaceTag).toBeDefined()
expect(surfaceTag).not.toContain('data-hover-card-trigger=""')
expect(markup.match(/data-hover-open-delay="250"/g)).toHaveLength(1)
expect(markup).toContain('Reviewer handoff note')
})
it('keeps child card markup inside the parent card by default', async () => {
settings = { compactWorktreeCards: false }
worktreeCardProperties = ['status', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ comment: 'Parent note' })}
repo={makeRepo()}
isActive={false}
lineageChildren={<div data-lineage-child-card="">Child card</div>}
/>
)
const childIndex = markup.indexOf('data-lineage-child-card=""')
const surfaceTag = markup.match(/<div[^>]*data-worktree-card-surface="true"[^>]*>/)?.[0]
expect(surfaceTag).toBeDefined()
expect(surfaceTag).not.toContain('data-hover-card-trigger=""')
expect(markup).toContain('data-worktree-lineage-children=""')
expect(childIndex).toBeGreaterThanOrEqual(0)
})
it('suppresses inline agent rows in compact cards by default', async () => {
settings = { compactWorktreeCards: true }
worktreeCardProperties = ['status', 'inline-agents']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('data-worktree-agents')
})
it('does not create a compact metadata row solely for an aggregate cache timer', async () => {
settings = { compactWorktreeCards: true }
worktreeCardProperties = ['status']
cacheTimerMocks.usePromptCacheCountdownStartedAt.mockImplementation(
(_worktreeId: string, active = true) => (active ? 10_000 : null)
)
const worktree = makeWorktree()
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(
worktree.id,
false
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
})
it('keeps unlink available for an auto-detected PR in the identity hover', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
const worktree = makeWorktree()
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({ url: '' }),
fetchedAt: Date.now(),
linkedReviewHintKey: 'github:456',
branchLookupGitHubPRNumber: 456
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('More PR actions')
})
it('suppresses the aggregate cache timer when compact inline agents are visible', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'inline-agents']
agentActivityDisplayMode = 'compact'
mockInlineAgentRows = [{} as DashboardAgentRowData]
const worktree = makeWorktree()
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('data-worktree-agents=""')
expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(
worktree.id,
false
)
})
it('keeps status and agent tooltip targets outside the worktree details hover trigger', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'inline-agents']
agentActivityDisplayMode = 'compact'
mockInlineAgentRows = [{} as DashboardAgentRowData]
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
const statusIndex = markup.indexOf('data-worktree-card-status-slot=""')
const triggerIndex = markup.indexOf('data-worktree-card-hover-trigger=""')
const hoverContentIndex = markup.indexOf('data-hover-card-content=""')
const agentsIndex = markup.indexOf('data-worktree-agents=""')
expectIdentityBodyIsHoverTrigger(markup)
expect(statusIndex).toBeGreaterThanOrEqual(0)
expect(statusIndex).toBeLessThan(triggerIndex)
expect(hoverContentIndex).toBeGreaterThan(triggerIndex)
expect(agentsIndex).toBeGreaterThan(hoverContentIndex)
})
it('preserves the aggregate cache timer when compact inline agents are enabled but absent', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'inline-agents']
agentActivityDisplayMode = 'compact'
mockInlineAgentRows = []
cacheTimerMocks.usePromptCacheCountdownStartedAt.mockImplementation(
(_worktreeId: string, active = true) => (active ? 10_000 : null)
)
const worktree = makeWorktree()
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('data-worktree-agents=""')
expect(markup).toContain('data-agent-count="0"')
expect(cacheTimerMocks.usePromptCacheCountdownStartedAt).toHaveBeenCalledWith(worktree.id, true)
})
it('keeps child card markup outside the parent hover trigger when new card style is on', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ comment: 'Parent note' })}
repo={makeRepo()}
isActive={false}
lineageChildren={<div data-lineage-child-card="">Child card</div>}
/>
)
const surfaceIndex = markup.indexOf('data-worktree-card-surface="true"')
const triggerIndex = markup.indexOf('data-worktree-card-hover-trigger=""')
const hoverContentIndex = markup.indexOf('data-hover-card-content=""')
const childIndex = markup.indexOf('data-lineage-child-card=""')
expectIdentityBodyIsHoverTrigger(markup)
expect(markup).toContain('data-worktree-lineage-children=""')
expect(markup).toContain('group/worktree-card')
expect(markup).not.toContain('group relative flex cursor-pointer')
expect(markup).not.toContain('group/worktree-card relative flex cursor-pointer')
expect(surfaceIndex).toBeGreaterThanOrEqual(0)
expect(triggerIndex).toBeGreaterThan(surfaceIndex)
expect(hoverContentIndex).toBeGreaterThanOrEqual(0)
expect(childIndex).toBeGreaterThan(hoverContentIndex)
})
it('uses a centered parent row and raised title size when no meta row is visible', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} hideRepoBadge />
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('data-worktree-card-parent-content=""')
expect(markup).toContain('items-center')
expect(markup).toContain('w-5 items-center')
expect(markup).toContain('text-[13px] leading-5')
})
it('does not show a folder path row in new-card mode when no project groups exist', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
displayName: 'Docs folder',
branch: '',
path: ' /Users/x/projects/my-app '
})}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
hideRepoBadge
/>
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('Docs folder')
expect(markup).not.toContain('/Users/x/projects/my-app')
})
it('shows a folder path row in new-card mode through the branch setting when project groups exist', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch']
projectGroups = [{ id: 'group-1' }]
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
displayName: 'Docs folder',
branch: '',
path: ' /Users/x/projects/my-app '
})}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
hideRepoBadge
/>
)
expect(markup).toContain('data-worktree-card-meta-row=""')
expect(markup).toContain(' /Users/x/projects/my-app ')
expect(markup).not.toContain('>Folder</span>')
})
it('keeps hidden folder path identity available from the new-card hover', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
projectGroups = [{ id: 'group-1' }]
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
displayName: 'Docs folder',
branch: '',
path: '/Users/x/projects/my-app'
})}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
hideRepoBadge
/>
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('data-hover-open-delay="100"')
expect(markup).toContain('/Users/x/projects/my-app')
expect(markup).toContain('Docs folder')
})
it('shows the branch row for migrated Default cards with branch enabled', async () => {
settings = { compactWorktreeCards: false }
worktreeCardProperties = ['status', 'branch']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Human title' })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('feature/local-branch')
})
it('keeps compact card branch hidden in the row but available from title hover by default', async () => {
settings = { compactWorktreeCards: true }
worktreeCardProperties = ['status', 'branch']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Human title' })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('data-hover-open-delay="100"')
expect(markup).toContain('feature/local-branch')
})
it('shows the branch row for compact cards when branch is enabled and new card style is on', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Human title' })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('feature/local-branch')
})
})
@@ -1,893 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts'
import type { PRInfo } from '../../../../../../shared/github/pull-request-types'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { COMPACT_WORKTREE_CARD_PROPERTIES } from '../../../../../../shared/worktree/card-properties'
import type { WorkspacePortScanResult } from '../../../../../../shared/workspace-ports'
const fetchHostedReviewForBranch = vi.fn()
const fetchIssue = vi.fn()
const fetchLinearIssue = vi.fn()
const openModal = vi.fn()
const updateWorktreeMeta = vi.fn()
let worktreeCardProperties: WorktreeCardProperty[] = ['status']
let hostedReviewCache: Record<string, unknown> = {}
let issueCache: Record<string, unknown> = {}
let prCache: Record<string, unknown> = {}
let workspacePortScan: WorkspacePortScanResult | null = null
let settings: Partial<GlobalSettings> | null = null
vi.mock('@/store', () => {
const getState = () => ({
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch,
fetchIssue,
fetchLinearIssue,
gitConflictOperationByWorktree: {},
hostedReviewCache,
issueCache,
linearIssueCache: {},
openModal,
prCache,
projectGroups: [],
remoteBranchConflictByWorktreeId: {},
settings,
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
updateWorktreeMeta,
workspacePortScan,
worktreeCardProperties
})
return {
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), {
getState
})
}
})
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('../activity/use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => 'active'
}))
vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({
useIsSleepingWorktree: () => false
}))
vi.mock('../activity/CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownStartedAt: () => null
}))
vi.mock('../activity/WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu',
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope'
}))
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#999999',
addedAt: 1
}
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo-1::/repo/worktrees/pr-456',
repoId: 'repo-1',
path: '/repo/worktrees/pr-456',
displayName: 'Fix stale GH PR',
branch: 'feature/local-branch',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
function makeHostedReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
return {
provider: 'github',
number: 456,
title: 'Fix stale GH PR',
state: 'open',
url: 'https://github.com/acme/orca/pull/456',
status: 'success',
updatedAt: '2026-05-17T00:00:00.000Z',
mergeable: 'MERGEABLE',
...overrides
}
}
function makePRInfo(overrides: Partial<PRInfo> = {}): PRInfo {
return {
number: 456,
title: 'Fix stale GH PR',
state: 'open',
url: 'https://github.com/acme/orca/pull/456',
checksStatus: 'success',
updatedAt: '2026-05-17T00:00:00.000Z',
mergeable: 'MERGEABLE',
...overrides
}
}
function renderWorktreeCardMarkup(element: ReactNode): string {
return renderToStaticMarkup(<>{element}</>)
}
function countAutomationCreatedLabels(markup: string): number {
return markup.match(/Created by automation/g)?.length ?? 0
}
function getInlineRenameTitleTag(markup: string): string {
const match = markup.match(/<span[^>]*data-worktree-title-inline-rename=""[^>]*>/)
expect(match).not.toBeNull()
return match?.[0] ?? ''
}
describe('WorktreeCard linked PR display', () => {
beforeEach(() => {
vi.clearAllMocks()
worktreeCardProperties = ['status']
hostedReviewCache = {}
issueCache = {}
prCache = {}
workspacePortScan = null
settings = null
})
it('keeps linked GH PR status out of the left status slot by default', async () => {
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('Active')
expect(markup).toContain('bg-emerald-500')
expect(markup).not.toContain('PR: Open')
expect(markup).not.toContain('Linked PR #456')
}, 20_000)
it('keeps compact toggle-off unread and read-title visuals legacy', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: false }
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({ status: 'failure' }),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const unreadMarkup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: 456, isUnread: true })}
repo={makeRepo()}
isActive={false}
/>
)
const readMarkup = renderWorktreeCardMarkup(
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
const readTitleTag = getInlineRenameTitleTag(readMarkup)
expect(unreadMarkup).toContain('aria-label="Mark as read"')
expect(unreadMarkup).toContain('text-amber-500')
expect(unreadMarkup).not.toContain('PR checks: Failed · Mark read')
expect(unreadMarkup).not.toContain('size-[13px] translate-x-px')
expect(readTitleTag).toContain('font-normal text-foreground')
expect(readTitleTag).not.toContain('text-foreground/80')
}, 20_000)
it('applies experimental unread status and read-title visuals only when enabled', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({ status: 'failure' }),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const unreadMarkup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: 456, isUnread: true })}
repo={makeRepo()}
isActive={false}
/>
)
const readMarkup = renderWorktreeCardMarkup(
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
expect(unreadMarkup).not.toContain('aria-label="Mark as read"')
expect(unreadMarkup).toContain('PR checks: Failed · Unread')
expect(unreadMarkup).not.toContain('Mark read')
expect(unreadMarkup).toContain('size-[13px] translate-x-px')
expect(unreadMarkup).not.toContain('lucide-bell')
expect(unreadMarkup).not.toContain('text-amber-500')
expect(unreadMarkup).toContain('data-worktree-status-lane-unread=""')
expect(unreadMarkup).toContain('data-worktree-unread-alert=""')
expect(unreadMarkup).toContain('bg-amber-500')
expect(getInlineRenameTitleTag(unreadMarkup)).toContain('font-semibold text-foreground')
expect(getInlineRenameTitleTag(readMarkup)).toContain('font-normal text-foreground/80')
}, 20_000)
it('shows linked GH PR status in the left status slot before hosted review details are cached when new card style is on', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('PR: Open')
expect(markup).not.toContain('Linked PR #456')
}, 20_000)
it('does not show cached branch PR details when the worktree has no linked PR', async () => {
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({ number: 456, title: 'Stale branch PR' }),
fetchedAt: Date.now(),
linkedReviewHintKey: 'github:456'
}
}
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({ number: 456, title: 'Stale branch PR' }),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('PR #456')
expect(markup).not.toContain('Stale branch PR')
})
it('shows branch-discovered GH PR status when the worktree has no linked PR', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({ number: 456, title: 'Branch PR', state: 'open' }),
fetchedAt: Date.now(),
linkedReviewHintKey: 'github:456',
branchLookupGitHubPRNumber: 456
}
}
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({ number: 456, title: 'Branch PR' }),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('PR checks: Passing')
expect(markup).toContain('text-emerald-500/80')
expect(markup).not.toContain('Branch')
})
it('shows branch-discovered hosted review providers without linked worktree metadata', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({
provider: 'bitbucket',
number: 789,
title: 'Bitbucket branch PR',
url: 'https://bitbucket.org/acme/orca/pull-requests/789'
}),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('PR checks: Passing')
expect(markup).not.toContain('Linked PR #789')
})
it('keeps the stored branch title by default when a hosted review title is available', async () => {
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview(),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
displayName: 'feature/local-branch',
linkedPR: 456
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('data-worktree-title-inline-rename=""')
expect(markup).toContain('>feature/local-branch</span>')
expect(markup).not.toContain('>Fix stale GH PR</span>')
})
it('uses the hosted review title when new card style is on and stored title is the branch', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview(),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
displayName: 'feature/local-branch',
linkedPR: 456
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('data-worktree-title-inline-rename=""')
expect(markup).toContain('>Fix stale GH PR</span>')
expect(markup).not.toContain('>feature/local-branch</span>')
})
it('shows task and notes metadata while keeping PR out of the right metadata list', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
linkedPR: 456,
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Linked issue #123')
expect(markup).toContain('Linked Linear ENG-123')
expect(markup).toContain('PR: Open')
expect(markup).not.toContain('Linked PR #456')
expect(markup).toContain('Workspace notes')
expect(markup).not.toContain('data-slot="badge"')
expect(markup).not.toContain('Loading issue')
expect(markup).not.toContain('Reviewer handoff note')
})
it('shows selected task and notes metadata on compact cards when new card style is on', async () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
linkedPR: 456,
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Linked issue #123')
expect(markup).toContain('Linked Linear ENG-123')
expect(markup).not.toContain('Linked PR #456')
expect(markup).toContain('Workspace notes')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).not.toContain('Reviewer handoff note')
})
it('hides individual metadata surfaces when their card properties are disabled', async () => {
worktreeCardProperties = []
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
linkedIssue: 123,
linkedLinearIssue: 'ENG-123',
linkedPR: 456,
comment: 'Reviewer handoff note'
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('Linked issue #123')
expect(markup).not.toContain('Linked Linear ENG-123')
expect(markup).not.toContain('Linked PR #456')
expect(markup).not.toContain('Workspace notes')
expect(markup).not.toContain('Reviewer handoff note')
})
it('shows automation-created workspaces as a metadata icon property', async () => {
worktreeCardProperties = ['status', 'automation']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(countAutomationCreatedLabels(markup)).toBe(1)
expect(markup).not.toContain('>Automation</span>')
}, 20_000)
it('hides the automation metadata icon in compact card mode by preset default', async () => {
settings = { compactWorktreeCards: true }
worktreeCardProperties = [...COMPACT_WORKTREE_CARD_PROPERTIES]
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(countAutomationCreatedLabels(markup)).toBe(0)
expect(markup).not.toContain('>Automation</span>')
expect(markup).not.toContain('Nightly triage automation')
}, 20_000)
it('shows the automation metadata icon in compact card mode when manually enabled', async () => {
settings = { compactWorktreeCards: true }
worktreeCardProperties = ['status', 'automation']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(countAutomationCreatedLabels(markup)).toBe(1)
expect(markup).not.toContain('>Automation</span>')
}, 20_000)
it('hides automation-created card surfaces when the Automation property is disabled', async () => {
worktreeCardProperties = ['status']
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({
automationProvenance: {
kind: 'created-by-automation',
automationId: 'automation-1',
automationNameSnapshot: 'Nightly triage automation',
automationRunId: 'run-1',
automationRunTitleSnapshot: 'Nightly triage run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1',
repoId: 'repo-1',
hostId: 'local'
}
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(countAutomationCreatedLabels(markup)).toBe(0)
expect(markup).not.toContain('>Automation</span>')
expect(markup).not.toContain('Nightly triage automation')
})
it('hides live port metadata when the Ports card property is disabled', async () => {
const worktree = makeWorktree()
workspacePortScan = {
platform: 'darwin',
scannedAt: 1,
ports: [
{
id: '127.0.0.1:58941:1234',
bindHost: '127.0.0.1',
connectHost: '127.0.0.1',
port: 58941,
pid: 1234,
processName: 'node',
protocol: 'http',
kind: 'workspace',
owner: {
worktreeId: worktree.id,
repoId: worktree.repoId,
displayName: worktree.displayName,
path: worktree.path,
confidence: 'cwd'
}
}
]
}
worktreeCardProperties = []
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('live port')
expect(markup).not.toContain('Live Ports')
expect(markup).not.toContain('58941')
})
it('renders linked PR status in the left status slot instead of the right metadata list', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: makeHostedReview({ status: 'failure' }),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard worktree={makeWorktree({ linkedPR: 456 })} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('PR checks: Failed')
expect(markup).toContain('text-rose-500/85')
expect(markup).not.toContain('Linked PR #456')
expect(markup).not.toContain('CI checks')
})
it('uses branch PR cache for the status slot before hosted-review metadata warms', async () => {
settings = { experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status']
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({
number: 6340,
title: 'Remove split terminal from onboarding checklist',
state: 'merged',
headSha: 'abc123',
checksStatus: 'success'
}),
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('PR: Merged')
expect(markup).toContain('text-purple-600/70')
expect(markup).not.toContain('Branch')
expect(markup).not.toContain('lucide-git-branch')
})
it('reads the local branch PR cache for a known local repo while a runtime is focused', async () => {
settings = {
activeRuntimeEnvironmentId: 'env-win',
experimentalNewWorktreeCardStyle: true
}
worktreeCardProperties = ['status']
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({
number: 6341,
title: 'Keep local PR status visible',
state: 'open',
checksStatus: 'pending'
}),
fetchedAt: Date.now()
},
'runtime:env-win::repo-1::feature/local-branch': {
data: null,
fetchedAt: Date.now()
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('PR checks: Pending')
expect(markup).not.toContain('Branch')
expect(markup).not.toContain('lucide-git-branch')
})
it('keeps the detailed right-side PR badge during a transient hosted-review miss', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
worktreeCardProperties = ['pr']
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: null,
fetchedAt: 100
}
}
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({
number: 6340,
title: 'Remove split terminal from onboarding checklist',
state: 'open',
checksStatus: 'success'
}),
fetchedAt: 200
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Linked PR #6340')
expect(markup).not.toContain('Linked PR #456')
})
it('keeps the detailed PR badge when a transient miss still has an older review hint', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
worktreeCardProperties = ['pr']
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: null,
fetchedAt: 100,
linkedReviewHintKey: 'github:999'
}
}
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({
number: 6340,
title: 'Remove split terminal from onboarding checklist',
state: 'open',
checksStatus: 'success'
}),
fetchedAt: 200
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Linked PR #6340')
expect(markup).not.toContain('Linked PR #456')
})
it('keeps durable non-GitHub linked review metadata ahead of branch PR cache', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
worktreeCardProperties = ['pr']
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: null,
fetchedAt: 100
}
}
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({
number: 6340,
title: 'Remove split terminal from onboarding checklist',
state: 'open',
checksStatus: 'success'
}),
fetchedAt: 200
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedGitLabMR: 77 })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('Linked MR #77')
expect(markup).not.toContain('Linked PR #6340')
})
it('does not resurrect an older PR cache entry after a newer hosted-review miss', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
worktreeCardProperties = ['pr']
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: null,
fetchedAt: 200
}
}
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({
number: 6340,
title: 'Remove split terminal from onboarding checklist',
state: 'open',
checksStatus: 'success'
}),
fetchedAt: 100
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('Linked PR #6340')
})
it('does not resurrect PR cache on the same millisecond as a hosted-review miss', async () => {
settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: false }
worktreeCardProperties = ['pr']
hostedReviewCache = {
'local::repo-1::feature/local-branch': {
data: null,
fetchedAt: 200
}
}
prCache = {
'repo-1::feature/local-branch': {
data: makePRInfo({
number: 6340,
title: 'Remove split terminal from onboarding checklist',
state: 'open',
checksStatus: 'success'
}),
fetchedAt: 200
}
}
const { default: WorktreeCard } = await import('./WorktreeCard')
const markup = renderWorktreeCardMarkup(
<WorktreeCard
worktree={makeWorktree({ linkedPR: null })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('Linked PR #6340')
})
})
@@ -1,610 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitConflictOperation } from '../../../../../../shared/git/status/git-status-types'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type WorktreeCardComponent from './WorktreeCard'
import type * as WorkspaceDeleteQuickAction from '../../workspace-actions/deletion/workspace-delete-quick-action'
const fetchHostedReviewForBranch = vi.fn()
const fetchIssue = vi.fn()
const openModal = vi.fn()
const updateWorktreeMeta = vi.fn()
let worktreeCardProperties: WorktreeCardProperty[] = ['status']
let tabsByWorktree: Record<string, { id: string }[]> = {}
let ptyIdsByTabId: Record<string, string[]> = {}
let browserTabsByWorktree: Record<string, { id: string }[]> = {}
let settings: Partial<GlobalSettings> | null = null
let projectGroups: unknown[] = []
let workspaceDeleteModifierPressed = false
let gitConflictOperationByWorktree: Record<string, GitConflictOperation> = {}
let WorktreeCard: typeof WorktreeCardComponent
vi.mock('@/store', () => {
const getState = () => ({
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch,
fetchIssue,
gitConflictOperationByWorktree,
hostedReviewCache: {},
issueCache: {},
openModal,
projectGroups,
remoteBranchConflictByWorktreeId: {},
settings,
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
browserTabsByWorktree,
ptyIdsByTabId,
tabsByWorktree,
updateWorktreeMeta,
worktreeCardProperties
})
return {
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), {
getState,
subscribe: () => () => {}
})
}
})
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('../activity/use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => 'idle'
}))
vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({
useIsSleepingWorktree: () => false
}))
vi.mock('../activity/CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownStartedAt: () => null
}))
vi.mock('../activity/WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope',
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu'
}))
vi.mock(
'../../workspace-actions/deletion/workspace-delete-quick-action',
async (importOriginal) => {
const actual = await importOriginal<typeof WorkspaceDeleteQuickAction>()
return {
...actual,
useWorkspaceDeleteModifierPressed: () => workspaceDeleteModifierPressed
}
}
)
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'orca',
badgeColor: '#999999',
addedAt: 1
}
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo-1::/repo/worktrees/quick-action',
repoId: 'repo-1',
path: '/repo/worktrees/quick-action',
displayName: 'Quick action',
branch: 'quick-action',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: true,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
function getBranchMetadataLabelTag(markup: string): string {
const labelTag = markup
.match(/<span[^>]*>quick-action<\/span>/g)
?.find((tag) => tag.includes('text-[11px]'))
expect(labelTag).toBeDefined()
return labelTag ?? ''
}
describe('WorktreeCard quick actions', () => {
beforeAll(async () => {
WorktreeCard = (await import('./WorktreeCard')).default
}, 20_000)
beforeEach(() => {
vi.clearAllMocks()
worktreeCardProperties = ['status']
tabsByWorktree = {}
ptyIdsByTabId = {}
browserTabsByWorktree = {}
settings = null
projectGroups = []
workspaceDeleteModifierPressed = false
gitConflictOperationByWorktree = {}
})
it('marks the unread toggle as a workspace-board-preserving action', () => {
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('aria-label="Mark as read"')
expect(markup).toContain('data-workspace-board-preserve-open=""')
})
it('renders repo identity in the detailed metadata row', () => {
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('aria-label="Project orca"')
expect(markup).toContain('>orca</span>')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('can render the current workspace with a secondary active surface', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree()}
repo={makeRepo()}
isActive
activeSurfaceVariant="secondary"
/>
)
expect(markup).toContain('data-worktree-card-active="secondary"')
expect(markup).not.toContain('bg-black/[0.08]')
expect(markup).not.toContain('dark:bg-white/[0.10]')
expect(markup).not.toContain('bg-sidebar-accent/45')
expect(markup).not.toContain('border-sidebar-ring/25')
expect(markup).not.toContain('ring-sidebar-ring/15')
})
it('marks the primary active workspace for token-driven selected styling', () => {
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive />
)
expect(markup).toContain('data-worktree-card-active="primary"')
expect(markup).toContain('data-worktree-card-surface="true"')
expect(markup).not.toContain('bg-black/[0.08]')
expect(markup).not.toContain('dark:bg-white/[0.10]')
expect(markup).not.toContain('border-black/[0.015]')
})
it('marks multi-selected workspaces for the shared selected style, except the active one', () => {
const selected = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} isMultiSelected />
)
const activeSelected = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive isMultiSelected />
)
expect(selected).toContain('data-worktree-card-selected="true"')
expect(activeSelected).not.toContain('data-worktree-card-selected')
})
it('renders folder directory name in the detailed metadata row without a Folder badge', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Docs folder', branch: '' })}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
/>
)
expect(markup).toContain('Docs folder')
expect(markup).not.toContain('>Folder</span>')
expect(markup).toContain('>quick-action</span>')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('renders synthetic folder workspace directory name in the detailed metadata row without a Folder badge', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
id: 'folder:folder-1',
displayName: 'Docs folder',
branch: '',
path: '/repo/worktrees/quick-action'
})}
repo={undefined}
isActive={false}
/>
)
expect(markup).toContain('Docs folder')
expect(markup).not.toContain('>Folder</span>')
expect(markup).toContain('>quick-action</span>')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('does not render a branch icon for synthetic folder workspace path identity', () => {
settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true }
worktreeCardProperties = ['status', 'branch']
projectGroups = [{ id: 'project-group-1' }]
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
id: 'folder:folder-1',
displayName: 'Docs folder',
branch: '',
path: '/repo/worktrees/quick-action'
})}
repo={undefined}
isActive={false}
/>
)
expect(markup).toContain('/repo/worktrees/quick-action')
expect(markup).not.toContain('lucide-git-branch')
})
it('does not render a pending first-agent rename title badge', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ pendingFirstAgentMessageRename: true })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toMatch(/Will be renamed from first agent message|rename pending/)
expect(markup).not.toContain(
'aria-label="This worktree will be renamed from the first agent message"'
)
expect(markup).not.toContain('rename pending')
expect(markup).not.toContain('This worktree will be renamed from the first agent message')
})
it('renders the failed first-agent rename title badge', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ firstAgentMessageRenameError: 'model could not name this' })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('aria-label="Auto-rename failed: view error"')
expect(markup).toContain('rename failed')
})
it('renders the failed first-agent rename title badge when rename is also pending', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
firstAgentMessageRenameError: 'model could not name this',
pendingFirstAgentMessageRename: true
})}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).toContain('aria-label="Auto-rename failed: view error"')
expect(markup).toContain('rename failed')
expect(markup).not.toContain('rename pending')
})
it('renders the migrated branch metadata row when branch is enabled', () => {
worktreeCardProperties = ['branch']
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'quick-action', branch: 'quick-action' })}
repo={makeRepo()}
isActive={false}
hideRepoBadge
/>
)
expect(markup).toContain('quick-action')
const branchLabelTag = getBranchMetadataLabelTag(markup)
expect(branchLabelTag).toContain('truncate')
expect(branchLabelTag).toContain('text-[11px]')
expect(branchLabelTag).toContain('text-muted-foreground')
expect(branchLabelTag).toContain('leading-none')
expect(markup).toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('tabindex="0"')
})
it('renders detached HEAD identity in detailed card metadata', () => {
worktreeCardProperties = []
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'orca', branch: '' })}
repo={makeRepo()}
isActive={false}
hideRepoBadge
/>
)
expect(markup).toContain('orca')
expect(markup).toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('Detached HEAD @ abc123')
expect(markup).toContain('Detached HEAD at abc123. You are viewing a commit, not a branch.')
expect(markup).toContain('tabindex="0"')
})
it('omits the repeated branch metadata row when compact cards are enabled', () => {
worktreeCardProperties = []
settings = { compactWorktreeCards: true }
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'quick-action', branch: 'quick-action' })}
repo={makeRepo()}
isActive={false}
hideRepoBadge
/>
)
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).toContain('tabindex="0"')
})
it('omits the branch metadata row when the workspace has a custom title', () => {
worktreeCardProperties = []
settings = { compactWorktreeCards: true }
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ displayName: 'Custom workspace', branch: 'quick-action' })}
repo={makeRepo()}
isActive={false}
hideRepoBadge
/>
)
expect(markup).toContain('Custom workspace')
expect(markup).not.toContain('>quick-action<')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
expect(markup).not.toContain('text-[11px] text-muted-foreground truncate leading-none')
})
it('uses the left status lane and primary badge when compact cards are disabled', () => {
worktreeCardProperties = ['status']
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
displayName: 'main',
branch: 'main',
isMainWorktree: true,
isSparse: true
})}
repo={makeRepo()}
isActive={false}
hideRepoBadge
/>
)
expect(markup).toContain('primary')
expect(markup).toContain('>sparse<')
expect(markup).not.toContain('aria-label="Primary worktree"')
expect(markup).not.toContain('aria-label="Sparse checkout"')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('keeps unread in the status lane and moves primary into the title row when compact cards are enabled', () => {
worktreeCardProperties = ['status']
settings = { compactWorktreeCards: true }
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
displayName: 'main',
branch: 'main',
isMainWorktree: true
})}
repo={makeRepo()}
isActive={false}
hideRepoBadge
/>
)
expect(markup).toContain('aria-label="Primary worktree"')
expect(markup).not.toContain('>primary<')
expect(markup).not.toContain('data-worktree-card-meta-row=""')
})
it.each([
[true, true],
[true, false],
[false, true],
[false, false]
])('shows applicable new-style identity icons (primary: %s, sparse: %s)', (primary, sparse) => {
settings = { experimentalNewWorktreeCardStyle: true }
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
isMainWorktree: primary,
isSparse: sparse,
sparseDirectories: ['src/renderer']
})}
repo={makeRepo()}
isActive={false}
hideRepoBadge
/>
)
expect(markup.includes('aria-label="Primary worktree"')).toBe(primary)
expect(markup.includes('aria-label="Sparse checkout"')).toBe(sparse)
expect(markup.includes('lucide-house')).toBe(primary)
expect(markup.includes('lucide-folder-minus')).toBe(sparse)
expect(markup).not.toContain('>primary<')
expect(markup).not.toContain('>sparse<')
if (sparse) {
expect(markup).toContain('src/renderer')
}
})
it('does not show the primary icon for a folder workspace', () => {
settings = { experimentalNewWorktreeCardStyle: true }
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ isMainWorktree: true })}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
/>
)
expect(markup).not.toContain('aria-label="Primary worktree"')
})
it('hides delete by default for an inactive workspace', () => {
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('aria-label="Delete workspace"')
})
it('shows delete as the top-right quick action while Option/Alt is held', () => {
workspaceDeleteModifierPressed = true
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('aria-label="Delete workspace"')
})
it('shows delete as the quick action for folder workspace instances while Option/Alt is held', () => {
workspaceDeleteModifierPressed = true
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({
id: 'repo-1::/repo::workspace:123e4567-e89b-12d3-a456-426614174000',
path: '/repo',
isMainWorktree: false
})}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
/>
)
expect(markup).toContain('aria-label="Delete workspace"')
})
it('shows delete for a current workspace while Option/Alt is held', () => {
workspaceDeleteModifierPressed = true
const worktree = makeWorktree()
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive isCurrentWorktree />
)
expect(markup).toContain('aria-label="Delete workspace"')
})
it('does not show delete for the main worktree while Option/Alt is held', () => {
workspaceDeleteModifierPressed = true
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree({ isMainWorktree: true })}
repo={makeRepo()}
isActive={false}
/>
)
expect(markup).not.toContain('aria-label="Delete workspace"')
})
it('does not replace sleep with delete for a workspace with live activity', () => {
const worktree = makeWorktree()
tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] }
ptyIdsByTabId = { 'tab-1': ['pty-1'] }
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('aria-label="Sleep workspace"')
expect(markup).not.toContain('aria-label="Delete workspace"')
})
it('does not show sleep as the top-right quick action for an active workspace', () => {
const worktree = makeWorktree()
tabsByWorktree = { [worktree.id]: [{ id: 'tab-1' }] }
ptyIdsByTabId = { 'tab-1': ['pty-1'] }
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive />
)
expect(markup).not.toContain('aria-label="Sleep workspace"')
expect(markup).not.toContain('aria-label="Delete workspace"')
})
it('does not show delete when the workspace is current but not selected in the sidebar', () => {
const worktree = makeWorktree()
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} isCurrentWorktree />
)
expect(markup).not.toContain('aria-label="Delete workspace"')
})
it('does not show the rebase operation chip on the card', () => {
const worktree = makeWorktree()
gitConflictOperationByWorktree = { [worktree.id]: 'rebase' }
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('Rebasing')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
it('keeps non-rebase operation chips on the card', () => {
const worktree = makeWorktree()
gitConflictOperationByWorktree = { [worktree.id]: 'merge' }
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('Merging')
expect(markup).toContain('data-worktree-card-meta-row=""')
})
})
@@ -1,304 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type WorktreeCardComponent from './WorktreeCard'
const fetchHostedReviewForBranch = vi.fn()
const fetchIssue = vi.fn()
const fetchLinearIssue = vi.fn()
const openModal = vi.fn()
const updateWorktreeMeta = vi.fn()
let WorktreeCard: typeof WorktreeCardComponent
let sshConnectionStates = new Map<string, { status: string }>()
let sshTargetLabels = new Map<string, string>()
let removedSshTargetLabels = new Map<string, string>()
let runtimeStatusByEnvironmentId = new Map<string, { status?: unknown }>()
let runtimeEnvironments: { id: string; name: string }[] = []
let sshStateByEnvironment = new Map()
let worktreesByRepo: Record<string, Worktree[]> = {}
let worktreeCardProperties: WorktreeCardProperty[] = ['status']
vi.mock('@/store', () => {
const getState = () => ({
deleteStateByWorktreeId: {},
fetchHostedReviewForBranch,
fetchIssue,
fetchLinearIssue,
gitConflictOperationByWorktree: {},
hostedReviewCache: {},
issueCache: {},
linearIssueCache: {},
openModal,
projectGroups: [],
remoteBranchConflictByWorktreeId: {},
runtimeEnvironments,
runtimeStatusByEnvironmentId,
removedSshTargetLabels,
settings: null,
sshConnectionStates,
sshStateByEnvironment,
sshTargetLabels,
sshTargetsHydrated: true,
updateWorktreeMeta,
worktreesByRepo,
worktreeCardProperties
})
return {
useAppStore: Object.assign((selector: (state: unknown) => unknown) => selector(getState()), {
getState,
subscribe: () => () => {}
})
}
})
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('../activity/CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownStartedAt: () => null
}))
vi.mock('../activity/WorktreeCardAgents', () => ({
default: () => null
}))
vi.mock('../activity/use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => 'idle'
}))
vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({
useIsSleepingWorktree: () => false
}))
vi.mock('../../workspace-actions/context-menu/WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope',
WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu'
}))
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'Remote repo',
badgeColor: '#999999',
addedAt: 1,
connectionId: 'ssh-target-1'
}
}
function makeWorktree(): Worktree {
return {
id: 'worktree-1',
repoId: 'repo-1',
path: '/repo/worktrees/one',
displayName: 'Remote workspace',
branch: 'remote-workspace',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1
}
}
describe('WorktreeCard SSH reconnect prompt', () => {
beforeAll(async () => {
WorktreeCard = (await import('./WorktreeCard')).default
}, 20_000)
beforeEach(() => {
vi.clearAllMocks()
sshConnectionStates = new Map()
sshTargetLabels = new Map()
removedSshTargetLabels = new Map()
runtimeStatusByEnvironmentId = new Map()
runtimeEnvironments = []
sshStateByEnvironment = new Map()
worktreesByRepo = {}
worktreeCardProperties = ['status']
})
it('offers an inline reconnect control and never a blocking dialog for a disconnected SSH worktree', () => {
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={true} />
)
expect(markup).toContain('Connect to SSH host Remote target')
expect(markup).toContain('Connect')
expect(markup).not.toContain('ssh-disconnected-dialog')
// Root opacity would also dim the reconnect tint/spinner that must remain visible.
expect(markup).not.toContain('opacity-60')
})
it('names the failure state in the control verb rather than a generic Connect', () => {
sshConnectionStates.set('ssh-target-1', { status: 'auth-failed' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('Reconnect SSH host Remote target')
expect(markup).toContain('authentication failed')
})
it('renders the passive host glyph, not a control, when the SSH host is connected', () => {
sshConnectionStates.set('ssh-target-1', { status: 'connected' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('Project on SSH host Remote target')
expect(markup).not.toContain('Connect to SSH host')
})
// connectionId is independent of workspace kind; SSH folders need the same reconnect control.
it('offers the control for a folder workspace on a disconnected SSH host', () => {
sshConnectionStates.set('ssh-target-1', { status: 'error' })
sshTargetLabels.set('ssh-target-1', 'Remote target')
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree()}
repo={{ ...makeRepo(), kind: 'folder' }}
isActive={false}
/>
)
expect(markup).toContain('Retry SSH connection to Remote target')
})
// Runtime-owned SSH targets are excluded from listTargets; absence cannot establish removal.
it('never reports a runtime-owned SSH target as removed', () => {
const markup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree()}
repo={{ ...makeRepo(), connectionId: 'runtime-ssh-abc123' }}
isActive={false}
/>
)
expect(markup).not.toContain('was removed')
expect(markup).toContain('Project on SSH host')
})
it('reports a removed SSH host once the target list no longer carries it', () => {
sshConnectionStates.set('ssh-target-1', { status: 'error' })
removedSshTargetLabels.set('ssh-target-1', 'Remote target (removed)')
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} />
)
expect(markup).toContain('was removed')
expect(markup).not.toContain('Retry SSH connection')
})
it('marks a runtime-host worktree disconnected once a probe finds it unreachable', () => {
runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }]
runtimeStatusByEnvironmentId.set('env-1', { status: null })
const runtimeRepo: Repo = {
...makeRepo(),
connectionId: undefined,
executionHostId: 'runtime:env-1'
}
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={runtimeRepo} isActive={false} />
)
expect(markup).toContain('Remote Mac disconnected')
})
// Unprobed hosts must not appear disconnected/dimmed during launch.
it('leaves a runtime-host worktree undimmed before its first probe answers', () => {
runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }]
const runtimeRepo: Repo = {
...makeRepo(),
connectionId: undefined,
executionHostId: 'runtime:env-1'
}
const markup = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={runtimeRepo} isActive={false} />
)
expect(markup).not.toContain('Remote Mac disconnected')
expect(markup).toContain('Project on Remote Mac')
expect(markup).not.toContain('opacity-60')
})
it('distinguishes connected worktrees on different Orca servers', () => {
runtimeEnvironments = [
{ id: 'env-1', name: 'Remote Mac' },
{ id: 'env-2', name: 'Build Linux' }
]
runtimeStatusByEnvironmentId.set('env-1', { status: { runtimeId: 'r1' } })
runtimeStatusByEnvironmentId.set('env-2', { status: { runtimeId: 'r2' } })
const remoteMacMarkup = renderToStaticMarkup(
<WorktreeCard
worktree={{ ...makeWorktree(), runtimeOwnerEnvironmentId: 'env-1' }}
repo={{ ...makeRepo(), connectionId: undefined, executionHostId: 'runtime:env-2' }}
isActive={false}
/>
)
const buildLinuxMarkup = renderToStaticMarkup(
<WorktreeCard
worktree={makeWorktree()}
repo={{ ...makeRepo(), connectionId: undefined, executionHostId: 'runtime:env-2' }}
isActive={false}
/>
)
expect(remoteMacMarkup).toContain('Project on Remote Mac')
expect(buildLinuxMarkup).toContain('Project on Build Linux')
})
it('reads nested SSH readiness from the owning HUB instead of client-local SSH state', () => {
runtimeStatusByEnvironmentId.set('hub-1', { status: { runtimeId: 'hub-runtime' } })
sshConnectionStates.set('ssh-target-1', { status: 'disconnected' })
sshTargetLabels.set('ssh-target-1', 'Misleading client-local target')
sshStateByEnvironment.set('hub-1', {
connectionStates: new Map([['ssh-target-1', { status: 'connected' }]]),
targetLabels: new Map([['ssh-target-1', 'HUB private target']]),
removedTargetLabels: new Map(),
targetsHydrated: true
})
const worktree = {
...makeWorktree(),
hostId: 'ssh:ssh-target-1' as const,
runtimeOwnerEnvironmentId: 'hub-1'
}
worktreesByRepo = { 'repo-1': [worktree] }
const markup = renderToStaticMarkup(
<WorktreeCard worktree={worktree} repo={makeRepo()} isActive={false} />
)
expect(markup).not.toContain('SSH disconnected')
expect(markup).toContain('data-ssh-target-label="HUB private target"')
expect(markup).not.toContain('Misleading client-local target')
})
})
@@ -1,732 +0,0 @@
import { renderToStaticMarkup } from 'react-dom/server'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { i18n } from '@/i18n/i18n'
import ko from '@/i18n/locales/ko.json'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import type { WorktreeCardPrDisplay } from '../review/worktree-card-pr-display'
import { summarizeWorkspaceReviewChecks } from '../../../../../../shared/workspace-review-checks'
import { getWorkspaceAttachmentKey } from '../../../../../../shared/workspace-attachments/workspace-attachment-normalization'
import {
referenceAttachment,
referenceReview
} from '../review/workspace-reference-fixtures.test-support'
const mocks = vi.hoisted(() => ({
status: 'active',
sleeping: false
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <span data-tooltip-root="">{children}</span>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<span data-tooltip-content="">{children}</span>
),
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('../activity/use-worktree-activity-status', () => ({
useWorktreeActivityStatus: () => mocks.status
}))
vi.mock('../../workspace-actions/sleep/use-worktree-sleep-state', () => ({
useIsSleepingWorktree: () => mocks.sleeping
}))
describe('WorktreeCardStatusSlot', () => {
afterEach(async () => {
await i18n.changeLanguage('en')
})
beforeEach(() => {
mocks.status = 'active'
mocks.sleeping = false
})
const review: WorktreeCardPrDisplay = {
provider: 'github',
number: 123,
title: 'Review me',
state: 'open',
status: 'failure'
}
const gitlabReview: WorktreeCardPrDisplay = {
provider: 'gitlab',
number: 456,
title: 'Review me',
state: 'open',
status: 'pending'
}
it.each([
{
state: 'merged',
provider: 'github',
status: 'success',
icon: 'merge',
tone: 'text-purple-600/70',
label: 'State: Merged',
locale: 'en',
stale: false
},
{
state: 'merged',
provider: 'github',
status: 'failure',
icon: 'merge',
tone: 'text-purple-600/70',
label: 'State: Merged',
locale: 'en',
stale: false
},
{
state: 'draft',
provider: 'github',
status: 'success',
icon: 'pull-request-draft',
tone: 'text-muted-foreground/50',
label: 'Reviews: Draft',
locale: 'en',
stale: false
},
{
state: 'closed',
provider: 'github',
status: 'success',
icon: 'pull-request-closed',
tone: 'text-muted-foreground/60',
label: 'State: Closed',
locale: 'en',
stale: false
},
{
state: 'merged',
provider: 'github',
status: undefined,
icon: 'merge',
tone: 'text-purple-600/70',
label: 'State: Merged',
locale: 'en',
stale: true
},
{
state: 'merged',
provider: 'github',
status: 'success',
icon: 'merge',
tone: 'text-purple-600/70',
label: ko.auto.components.sidebar.WorktreeCardMetadataStatusBadges.f394b3e86e,
locale: 'ko',
stale: false
},
{
state: 'draft',
provider: 'gitlab',
status: 'success',
icon: 'pull-request-draft',
tone: 'text-muted-foreground/50',
label: 'Reviews: Draft',
locale: 'en',
stale: false
}
] as const)(
'preserves $provider $state collection lifecycle with $status checks ($locale, stale=$stale)',
async ({ state, provider, status, icon, tone, label, locale, stale }) => {
await i18n.changeLanguage(locale)
const items = [referenceAttachment(1), referenceAttachment(2)].map((item) =>
provider === 'gitlab'
? {
...item,
provider,
type: 'mr' as const,
url: `https://gitlab.com/acme/orca/-/merge_requests/${item.number}`
}
: item
)
const details = Object.fromEntries(
items.map((item) => [
getWorkspaceAttachmentKey(item),
{
stale,
review: { ...referenceReview(item.number, { state }), provider, url: item.url, status }
}
])
)
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip=""
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
prDisplay={{ ...review, state, status }}
reviewChecks={summarizeWorkspaceReviewChecks(items, details)}
/>
)
expect(markup).toContain(`lucide-git-${icon}`)
expect(markup).toContain(tone)
expect(markup).not.toContain('text-emerald-500')
expect(markup).toContain(label)
expect(markup).not.toContain('Draft PR')
if (stale) {
expect(markup).toContain('Last known status')
expect(markup).toContain('shimmer')
expect(markup).toContain('Checks: Unknown')
}
}
)
it('uses checks from every attached review rather than the legacy passing review', () => {
const items = [referenceAttachment(1), referenceAttachment(2)]
const details = Object.fromEntries(
items.map((item) => [
getWorkspaceAttachmentKey(item),
{
review: referenceReview(item.number, {
status: item.number === 1 ? 'success' : 'failure'
})
}
])
)
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip=""
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
prDisplay={{ ...review, status: 'success' }}
reviewChecks={summarizeWorkspaceReviewChecks(items, details)}
/>
)
expect(markup).toContain('Reviews: 2 · Checks: Failed')
expect(markup).toContain('State: Open')
expect(markup).toContain('text-emerald-500/80')
expect(markup).not.toContain('checks: Passing')
})
it('keeps incomplete collection checks neutral instead of claiming passing', () => {
const items = [referenceAttachment(1), referenceAttachment(2)]
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip=""
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
prDisplay={{ ...review, status: 'success' }}
reviewChecks={summarizeWorkspaceReviewChecks(items, {})}
/>
)
expect(markup).toContain('Reviews: 2 · Checks: Unknown')
expect(markup).toContain('text-muted-foreground')
expect(markup).not.toContain('text-emerald-500')
})
it.each([{ items: [] }, { items: [referenceAttachment(1)] }])(
'preserves discovered and singleton review lifecycle glyphs',
({ items }) => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip=""
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
prDisplay={{ ...review, state: 'draft' }}
reviewChecks={summarizeWorkspaceReviewChecks(items, {})}
/>
)
expect(markup).toContain('PR: Draft')
expect(markup).toContain('lucide-git-pull-request-draft')
expect(markup).not.toContain('Reviews:')
}
)
it('lets the unread bell replace the visual status dot by default', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
/>
)
expect(markup).toContain('aria-label="Mark as read"')
expect(markup).toContain('Mark as read')
expect(markup).not.toContain('Active · Mark as read')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).toContain('text-amber-500')
})
it('overlays an unread badge on the status dot when new card style is on', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
/>
)
expect(markup).not.toContain('aria-label="Mark as read"')
expect(markup).not.toContain('Mark as read')
expect(markup).toContain('Active · Unread')
expect(markup).toContain('data-worktree-status-lane-unread=""')
expect(markup).toContain('data-worktree-unread-alert=""')
expect(markup).toContain('bg-amber-500')
expect(markup).toContain('bg-emerald-500')
expect(markup).not.toContain('lucide-bell')
expect(markup).not.toContain('text-amber-500')
})
it('suppresses the new-card unread badge while unread status is working', () => {
mocks.status = 'working'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
/>
)
expect(markup).toContain('Working · Unread')
expect(markup).toContain('border-agent-working')
expect(markup).not.toContain('data-worktree-status-lane-unread=""')
expect(markup).not.toContain('data-worktree-unread-alert=""')
expect(markup).not.toContain('aria-label="Mark as read"')
expect(markup).not.toContain('lucide-bell')
expect(markup).not.toContain('text-amber-500')
})
it('suppresses the new-card unread badge while unread status is permission', () => {
mocks.status = 'permission'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
/>
)
expect(markup).toContain('Needs permission · Unread')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-agent-question')
expect(markup).not.toContain('data-worktree-status-lane-unread=""')
expect(markup).not.toContain('data-worktree-unread-alert=""')
expect(markup).not.toContain('aria-label="Mark as read"')
expect(markup).not.toContain('lucide-bell')
})
it('keeps legacy unread working cards on the unread bell control', () => {
mocks.status = 'working'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
/>
)
expect(markup).toContain('aria-label="Mark as read"')
expect(markup).toContain('Mark as read')
expect(markup).toContain('Working')
expect(markup).toContain('text-amber-500')
expect(markup).not.toContain('border-agent-working')
expect(markup).not.toContain('data-worktree-unread-alert=""')
})
it('shows status in the unread toggle affordance', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
/>
)
expect(markup).toContain('Active · Mark as unread')
expect(markup).toContain('bg-emerald-500')
expect(markup.match(/data-tooltip-root/g)).toHaveLength(1)
})
it('keeps the quiet active dot ahead of PR status by default', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
/>
)
expect(markup).toContain('Active')
expect(markup).toContain('bg-emerald-500')
expect(markup).not.toContain('PR checks: Failed')
})
it('uses PR status instead of the quiet active dot when new card style is on', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).toContain('PR checks: Failed')
expect(markup).toContain('inline-flex size-5 items-center justify-center')
expect(markup).toContain('size-[13px] translate-x-px')
expect(markup).toContain('text-rose-500/85')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).not.toContain('data-tooltip-root')
})
it('marks the review status as last known while its WSL host is off (E9)', () => {
const render = (reviewStale: boolean) =>
renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
reviewStale={reviewStale}
/>
)
const stale = render(true)
expect(stale).toContain('PR checks: Failed · Last known status')
expect(stale).toContain('shimmer')
// The cached badge is kept, not dropped.
expect(stale).toContain('text-rose-500/85')
expect(render(false)).not.toContain('Last known status')
})
it('uses the unified compact review glyph for GitLab MR status', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={gitlabReview}
newCardStyle
/>
)
expect(markup).toContain('MR checks: Pending')
expect(markup).toContain('viewBox="0 0 16 16"')
expect(markup).toContain('size-[13px] translate-x-px')
expect(markup).toContain('text-amber-500/85')
expect(markup).not.toContain('lucide-git-merge')
})
it('uses PR status instead of the quiet done dot when new card style is on', () => {
mocks.status = 'done'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).toContain('PR checks: Failed')
expect(markup).not.toContain('bg-emerald-500')
})
it('keeps sleeping distinct from PR status when new card style is on', () => {
// Sleeping retains done rows; test that state rather than inactive.
mocks.status = 'done'
mocks.sleeping = true
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).toContain('Sleeping')
expect(markup).toContain('lucide-moon')
expect(markup).not.toContain('PR checks: Failed')
expect(markup).not.toContain('text-rose-500/85')
expect(markup).not.toContain('bg-neutral-500/40')
})
it('keeps sleeping moon distinct from the awake green dot when new card style is on', () => {
mocks.status = 'done'
mocks.sleeping = true
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
/>
)
expect(markup).toContain('Sleeping')
expect(markup).toContain('lucide-moon')
expect(markup).not.toContain('lucide-git-branch')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).not.toContain('bg-neutral-500/40')
})
it('distinguishes awake branch from sleeping moon when new card style is on', () => {
mocks.status = 'done'
const awakeMarkup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
hasBranchIdentity
/>
)
expect(awakeMarkup).toContain('Branch')
expect(awakeMarkup).toContain('lucide-git-branch')
expect(awakeMarkup).not.toContain('lucide-moon')
mocks.sleeping = true
const sleepingMarkup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
hasBranchIdentity
/>
)
expect(sleepingMarkup).toContain('Sleeping')
expect(sleepingMarkup).toContain('lucide-moon')
expect(sleepingMarkup).not.toContain('lucide-git-branch')
})
it('shows the green awake dot for quiet done workspaces when new card style is on', () => {
mocks.status = 'done'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
/>
)
expect(markup).toContain('Done')
expect(markup).toContain('bg-emerald-500')
expect(markup).not.toContain('lucide-git-branch')
expect(markup).not.toContain('lucide-moon')
expect(markup).toContain('data-tooltip-root')
})
it('keeps working activity ahead of PR status in new card style', () => {
mocks.status = 'working'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).toContain('Working')
expect(markup).toContain('inline-flex size-5 items-center justify-center')
expect(markup).toContain('border-agent-working')
expect(markup).toContain('data-tooltip-root')
expect(markup).toContain('data-tooltip-content="">Working')
expect(markup).not.toContain('PR checks: Failed')
})
it('keeps permission activity ahead of PR status in new card style', () => {
mocks.status = 'permission'
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction={false}
isUnread={false}
unreadTooltip="Mark as unread"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).toContain('Needs permission')
expect(markup).toContain('lucide-message-circle-question-mark')
expect(markup).toContain('text-agent-question')
expect(markup).toContain('data-tooltip-root')
expect(markup).toContain('data-tooltip-content="">Needs permission')
expect(markup).not.toContain('PR checks: Failed')
})
it('keeps unread ahead of PR status by default', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
/>
)
expect(markup).toContain('aria-label="Mark as read"')
expect(markup).toContain('Mark as read')
expect(markup).not.toContain('Active · Mark as read')
expect(markup).not.toContain('PR checks: Failed')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).toContain('text-amber-500')
})
it('overlays an unread badge on PR status when new card style is on', () => {
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
prDisplay={review}
newCardStyle
/>
)
expect(markup).not.toContain('aria-label="Mark as read"')
expect(markup).not.toContain('Mark as read')
expect(markup).toContain('PR checks: Failed · Unread')
expect(markup).toContain('data-worktree-status-lane-unread=""')
expect(markup).toContain('data-worktree-unread-alert=""')
expect(markup).not.toContain('group/unread')
expect(markup).not.toContain('cursor-pointer')
expect(markup).toContain('text-rose-500/85')
expect(markup).toContain('bg-amber-500')
expect(markup).not.toContain('lucide-bell')
expect(markup).not.toContain('text-amber-500')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).not.toContain('data-tooltip-root')
})
it('overlays an unread badge on the sleeping moon in new card style', () => {
mocks.status = 'done'
mocks.sleeping = true
const markup = renderToStaticMarkup(
<WorktreeCardStatusSlot
worktreeId="wt-1"
showStatus
showUnreadAction
isUnread
unreadTooltip="Mark as read"
onPointerDown={vi.fn()}
onToggleUnread={vi.fn()}
newCardStyle
/>
)
expect(markup).toContain('Sleeping · Unread')
expect(markup).toContain('data-worktree-status-lane-unread=""')
expect(markup).toContain('data-worktree-unread-alert=""')
expect(markup).not.toContain('Mark as read')
expect(markup).not.toContain('group/unread')
expect(markup).not.toContain('cursor-pointer')
expect(markup).toContain('lucide-moon')
expect(markup).toContain('bg-amber-500')
expect(markup).not.toContain('lucide-bell')
expect(markup).not.toContain('text-amber-500')
expect(markup).not.toContain('bg-emerald-500')
expect(markup).not.toContain('data-tooltip-root')
})
})
@@ -1,11 +1,11 @@
// @vitest-environment happy-dom
import React, { type ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import type { HostedReviewInfo } from '../../../../../../shared/source-control/hosted-review/hosted-review-contracts'
import type { Repo } from '../../../../../../shared/repo-types'
import type { WorktreeCardProperty } from '../../../../../../shared/ui-chrome-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
@@ -135,20 +135,6 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
}
}
function review(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
return {
provider: 'github',
number: 456,
title: 'Card plugins',
state: 'open',
url: 'https://github.com/acme/orca/pull/456',
status: 'failure',
updatedAt: '2026-11-02T00:00:00.000Z',
mergeable: 'CONFLICTING',
...overrides
}
}
type Case = {
worktree?: Partial<Worktree>
repo?: Partial<Repo> | null
@@ -159,107 +145,7 @@ type Case = {
deleteModifier?: boolean
}
const wt = makeWorktree()
const CASES: Record<string, Case> = {
plain: {},
titleOnly: { cardProps: ['status'], worktree: { branch: '' } },
noStatus: { cardProps: ['issue', 'pr', 'ports', 'inline-agents', 'host'] },
noInlineAgents: { cardProps: ['status', 'unread', 'pr', 'host'] },
branchMatchesName: { worktree: { displayName: 'feature/card-plugins' } },
unread: { worktree: { isUnread: true } },
main: { worktree: { isMainWorktree: true, branch: 'refs/heads/main' } },
sparse: { worktree: { isSparse: true, sparseDirectories: ['src', 'docs'] } },
mainSparse: {
worktree: { isMainWorktree: true, isSparse: true, sparseDirectories: ['src', 'docs'] }
},
renameFailed: { worktree: { firstAgentMessageRenameError: 'agent said no' } },
removalError: { worktree: { removalError: '/repo/worktrees/feature: busy' } },
detached: { worktree: { branch: '', head: 'deadbeef' } },
merging: { state: { gitConflictOperationByWorktree: { [wt.id]: 'merge' } } },
rebasing: { state: { gitConflictOperationByWorktree: { [wt.id]: 'rebase' } } },
remoteConflict: {
state: {
remoteBranchConflictByWorktreeId: {
[wt.id]: { remote: 'origin', branchName: 'feature/card-plugins' }
}
}
},
prFailing: {
worktree: { linkedPR: 456 },
state: {
hostedReviewCache: {
'local::repo-1::feature/card-plugins': { data: review(), fetchedAt: 1 }
}
}
},
details: {
worktree: {
linkedIssue: 12,
linkedLinearIssue: 'ORC-1',
comment: 'Waiting on review',
automationProvenance: {
kind: 'created-by-automation',
automationId: 'a1',
automationNameSnapshot: 'Nightly',
automationRunId: 'r1',
automationRunTitleSnapshot: 'Nightly run',
createdAt: 1,
executionTargetType: 'local',
executionTargetId: 'local',
projectId: 'repo-1'
}
}
},
twoAttachments: {
worktree: {
linkedItems: [
{ type: 'pr', provider: 'github', number: 1, url: 'https://github.com/a/b/pull/1' },
{ type: 'pr', provider: 'github', number: 2, url: 'https://github.com/a/b/pull/2' }
]
}
},
cacheTimer: { cacheStartedAt: 1000 },
deleteQuickAction: { deleteModifier: true },
ssh: { repo: { connectionId: 'ssh-1' } },
runtime: { repo: { executionHostId: 'runtime:env-1' } },
folder: { repo: { kind: 'folder' }, worktree: { branch: '' } },
folderWithGroups: {
repo: { kind: 'folder' },
worktree: { branch: '' },
state: { projectGroups: [{ id: 'g1' }] }
},
noRepo: { repo: null },
pinned: { props: { inPinnedSection: true } },
pinnedWithProject: { props: { inPinnedSection: true, showProjectName: true } },
hideRepoBadge: { props: { hideRepoBadge: true } },
hostLabel: { props: { hostContextLabel: 'build-box' } },
hostLabelWithProject: { props: { hostContextLabel: 'build-box', showProjectName: true } },
lineage: {
props: {
lineageChildCount: 2,
lineageChildWorktreeIds: ['c1', 'c2'],
onLineageToggle: () => {}
}
},
affiliate: { props: { affiliateListMode: true } },
activeLinear: { props: { isActive: true }, worktree: { linkedLinearIssue: 'ORC-2' } },
flushIndented: { props: { flushSurface: true, contentIndent: 24 } },
indented: { props: { contentIndent: 16 } }
}
// Proof each case draws what it is named for, so a parity pass is not two empty cards.
const SHOWS: Partial<Record<keyof typeof CASES, string>> = {
sparse: 'sparse',
renameFailed: 'rename failed',
removalError: 'data-worktree-card-delete-failed',
merging: 'Merging',
remoteConflict: 'already exists',
cacheTimer: 'data-cache-timer',
deleteQuickAction: 'Delete workspace',
lineage: 'data-lineage-toggle',
details: 'Waiting on review',
activeLinear: 'Linear'
}
const STYLES: Record<string, Partial<GlobalSettings>> = {
classic: { compactWorktreeCards: false },
@@ -347,47 +233,8 @@ function Harness({ props, variant }: { props: WorktreeCardProps; variant: 'legac
}
/** Attribute order and class token order do not change what the browser draws. */
function canonical(html: string): string {
const host = document.createElement('div')
host.innerHTML = html
const walk = (node: Node): string => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent ?? ''
}
if (!(node instanceof Element)) {
return ''
}
const attrs = [...node.attributes]
.map((attr) =>
attr.name === 'class'
? `class="${attr.value.split(/\s+/).filter(Boolean).sort().join(' ')}"`
: `${attr.name}="${attr.value}"`
)
.sort()
return `<${node.localName} ${attrs.join(' ')}>${[...node.childNodes].map(walk).join('')}</${node.localName}>`
}
// React's generated ids depend on tree position, not on what is drawn.
const ids = new Map<string, string>()
return [...host.childNodes]
.map(walk)
.join('')
.replace(/_R_[A-Za-z0-9]+_/g, (id) => {
if (!ids.has(id)) {
ids.set(id, `id${ids.size}`)
}
return ids.get(id) ?? id
})
}
// Why a window: full card markup is too long for a readable assertion diff.
function aroundFirstDifference(actual: string, expected: string): [string, string] {
let index = 0
while (index < actual.length && actual[index] === expected[index]) {
index++
}
const start = Math.max(0, index - 120)
return [actual.slice(start, index + 240), expected.slice(start, index + 240)]
}
describe('default workspace card template parity with the original card', () => {
beforeEach(() => {
@@ -397,33 +244,6 @@ describe('default workspace card template parity with the original card', () =>
})
afterEach(cleanup)
for (const [styleName, style] of Object.entries(STYLES)) {
for (const [caseName, testCase] of Object.entries(CASES)) {
it(`${styleName}: ${caseName}`, () => {
mocks.cacheStartedAt = testCase.cacheStartedAt ?? null
mocks.deleteModifier = testCase.deleteModifier ?? false
mocks.state = stateFor(style, testCase)
const props: WorktreeCardProps = {
worktree: makeWorktree(testCase.worktree),
repo: testCase.repo === null ? undefined : { ...repo, ...testCase.repo },
isActive: false,
...testCase.props
}
const legacy = canonical(renderToStaticMarkup(<Harness props={props} variant="legacy" />))
const template = canonical(
renderToStaticMarkup(<Harness props={props} variant="template" />)
)
const shows =
caseName === 'sparse' && styleName === 'new' ? 'Sparse checkout' : SHOWS[caseName]
if (shows) {
expect(legacy).toContain(shows)
}
const [actualWindow, expectedWindow] = aroundFirstDifference(template, legacy)
expect(actualWindow).toBe(expectedWindow)
})
}
}
it('suppresses a delayed details hover while a template identity icon is focused', () => {
mocks.state = stateFor(STYLES.new, {})
const { container } = render(
@@ -80,44 +80,6 @@ afterEach(() => {
})
describe('folder composer cache expiry requests', () => {
it('retains unrelated-expiry and reopen retries after an uncached failure', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
getPathStatus.mockRejectedValueOnce(new Error('Unavailable'))
const hook = renderHook(({ open }) => useFolderWorkspaceComposerPathStatus(group, open, null), {
initialProps: { open: true }
})
await act(async () => {})
await expireUnrelatedEntries()
expect(getPathStatus).toHaveBeenCalledTimes(2)
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
hook.rerender({ open: false })
hook.rerender({ open: true })
await act(async () => {})
expect(getPathStatus).toHaveBeenCalledTimes(3)
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
})
it('does not refetch a fresh selected folder when other entries expire', async () => {
const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null))
await act(async () => {})
await expireUnrelatedEntries()
expect(getPathStatus).toHaveBeenCalledExactlyOnceWith(request)
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
})
it('retains expiry-triggered checks while the first result is unknown', async () => {
const gate = Promise.withResolvers<{ path: string; exists: boolean }>()
getPathStatus.mockReturnValue(gate.promise)
const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null))
await expireUnrelatedEntries()
expect(getPathStatus).toHaveBeenCalledTimes(101)
expect(hook.result.current.pathStatusBlocksCreate).toBe(true)
await act(async () => gate.resolve({ path: '/folder', exists: true }))
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
})
it('refreshes at the selected entry expiry and blocks while that refresh is pending', async () => {
useAppStore.setState({ folderWorkspacePathStatuses: {} })
const gate = Promise.withResolvers<{ path: string; exists: boolean }>()
@@ -136,12 +98,6 @@ describe('folder composer cache expiry requests', () => {
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
})
it('does not check a folder without a selected group', async () => {
renderHook(() => useFolderWorkspaceComposerPathStatus(null, true, null))
await expireUnrelatedEntries()
expect(getPathStatus).not.toHaveBeenCalled()
})
it.each(['missing', 'not-directory', 'ambiguous-connection'] as const)(
'retains unrelated-expiry recovery after a cached %s refresh fails',
async (reason) => {
@@ -175,58 +131,6 @@ describe('folder composer cache expiry requests', () => {
}
)
it('forces the initial check even when a matching fresh result is already cached', async () => {
const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request, {
runtimeEnvironmentId: null
})
expect(snapshot).not.toBeNull()
useAppStore.setState({
folderWorkspacePathStatuses: {
'local:project-group:selected': {
checkedAt: Date.now(),
requestSnapshot: snapshot ?? '',
status: { path: '/folder', exists: false, reason: 'missing' }
}
}
})
const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null))
await act(async () => {})
expect(getPathStatus).toHaveBeenCalledExactlyOnceWith(request)
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
})
it('retries a fresh timestamp whose request snapshot is no longer current', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
getPathStatus.mockRejectedValueOnce(new Error('Unavailable'))
useAppStore.setState((state) => ({
folderWorkspacePathStatuses: {
...state.folderWorkspacePathStatuses,
'local:project-group:selected': {
checkedAt: Date.now(),
requestSnapshot: 'old-folder-snapshot',
status: { path: '/old-folder', exists: false, reason: 'missing' }
}
}
}))
const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null))
await act(async () => {})
expect(hook.result.current.pathStatusBlocksCreate).toBe(true)
await act(async () => vi.advanceTimersByTimeAsync(11))
expect(getPathStatus).toHaveBeenCalledTimes(2)
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
})
it('does not report a nonexistent pending check when cache is cleared after a skipped expiry', async () => {
const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, null))
await act(async () => {})
await act(async () => vi.advanceTimersByTimeAsync(11))
expect(getPathStatus).toHaveBeenCalledOnce()
await act(async () => useAppStore.setState({ folderWorkspacePathStatuses: {} }))
expect(getPathStatus).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
})
it('keeps a real pending check pending when fresh cache is removed after unrelated expiry', async () => {
const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request, {
runtimeEnvironmentId: null
@@ -252,67 +156,4 @@ describe('folder composer cache expiry requests', () => {
await act(async () => gate.resolve({ path: '/folder', exists: true }))
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
})
it('uses the selected runtime cache entry instead of a same-group local entry', async () => {
const fetchStatus = vi.fn().mockResolvedValue(null)
useAppStore.setState({
projectGroups: [group, { ...group, executionHostId: 'runtime:env-1' }]
})
const snapshot = getFolderWorkspaceStatusRequestSnapshot(useAppStore.getState(), request, {
runtimeEnvironmentId: 'env-1'
})
expect(snapshot).not.toBeNull()
const makeEntry = (checkedAt: number): FolderWorkspacePathStatusCacheEntry => ({
checkedAt,
status: { path: '/folder', exists: true },
requestSnapshot: snapshot ?? ''
})
useAppStore.setState({
fetchFolderWorkspacePathStatus: fetchStatus,
folderWorkspacePathStatuses: {
'local:project-group:selected': makeEntry(90_010),
'environment:env-1:project-group:selected': makeEntry(100_000)
}
})
const hook = renderHook(() => useFolderWorkspaceComposerPathStatus(group, true, 'env-1'))
await act(async () => {})
await act(async () => vi.advanceTimersByTimeAsync(11))
expect(fetchStatus).toHaveBeenCalledExactlyOnceWith(request, {
force: true,
runtimeEnvironmentId: 'env-1'
})
expect(hook.result.current.pathStatusBlocksCreate).toBe(false)
await act(async () => vi.advanceTimersByTimeAsync(9990))
expect(fetchStatus).toHaveBeenCalledTimes(2)
})
it('replaces the timer when the selected group changes', async () => {
const secondGroup: ProjectGroup = { ...group, id: 'second', parentPath: '/second' }
useAppStore.setState({ projectGroups: [group, secondGroup], folderWorkspacePathStatuses: {} })
getPathStatus.mockImplementation(async (input) => ({
path:
input.scope === 'project-group' && input.projectGroupId === 'second'
? '/second'
: '/folder',
exists: true
}))
const hook = renderHook(
({ selected }) => useFolderWorkspaceComposerPathStatus(selected, true, null),
{
initialProps: { selected: group }
}
)
await act(async () => {})
await act(async () => vi.advanceTimersByTimeAsync(5000))
hook.rerender({ selected: secondGroup })
await act(async () => {})
getPathStatus.mockClear()
await act(async () => vi.advanceTimersByTimeAsync(5001))
expect(getPathStatus).not.toHaveBeenCalled()
await act(async () => vi.advanceTimersByTimeAsync(5000))
expect(getPathStatus).toHaveBeenCalledExactlyOnceWith({
scope: 'project-group',
projectGroupId: 'second'
})
})
})
@@ -1,274 +0,0 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import { useAppStore } from '@/store/index'
import { getFolderWorkspaceStatusRequestSnapshot } from '@/store/folder-workspaces/folder-path-status'
import { useFolderWorkspaceComposerPathStatus } from './folder-workspace-composer-path-status'
const initialState = useAppStore.getInitialState()
const projectGroup: ProjectGroup = {
id: 'group-1',
name: 'Platform',
parentPath: '/workspace/platform',
connectionId: null,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const projectGroupRequestSnapshot = getFolderWorkspaceStatusRequestSnapshot(
{ ...initialState, projectGroups: [projectGroup] },
{ scope: 'project-group', projectGroupId: projectGroup.id },
{ runtimeEnvironmentId: null }
)
if (projectGroupRequestSnapshot === null) {
throw new Error('Project group fixture must have a path status snapshot')
}
let root: Root | null = null
let container: HTMLDivElement | null = null
function HookProbe(): null {
const result = useFolderWorkspaceComposerPathStatus(projectGroup, true, null)
;(
globalThis as { __folderWorkspaceComposerPathStatusResult?: typeof result }
).__folderWorkspaceComposerPathStatusResult = result
return null
}
describe('useFolderWorkspaceComposerPathStatus', () => {
afterEach(() => {
vi.useRealTimers()
act(() => {
root?.unmount()
})
root = null
container?.remove()
container = null
delete (globalThis as { __folderWorkspaceComposerPathStatusResult?: unknown })
.__folderWorkspaceComposerPathStatusResult
useAppStore.setState(initialState, true)
})
it('blocks creation while an expired path status refresh is pending', async () => {
vi.useFakeTimers()
vi.setSystemTime(20_000)
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const cacheKey = useAppStore
.getState()
.getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null })
const fetchFolderWorkspacePathStatus = vi.fn().mockResolvedValue(null)
useAppStore.setState({
projectGroups: [projectGroup],
fetchFolderWorkspacePathStatus,
folderWorkspacePathStatuses: {
[cacheKey]: {
status: {
path: '/workspace/platform',
exists: false,
reason: 'missing'
},
checkedAt: 0,
requestSnapshot: projectGroupRequestSnapshot
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<HookProbe />)
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(true)
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusProjectError: string | null }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusProjectError
).toContain('/workspace/platform')
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(true)
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusProjectError: string | null }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusProjectError
).toContain('/workspace/platform')
expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, {
force: true,
runtimeEnvironmentId: null
})
})
it('unblocks creation when the first path status check settles without cache', async () => {
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const fetchFolderWorkspacePathStatus = vi.fn().mockResolvedValue(null)
useAppStore.setState({
projectGroups: [projectGroup],
fetchFolderWorkspacePathStatus,
folderWorkspacePathStatuses: {}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<HookProbe />)
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(true)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(false)
expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, {
force: true,
runtimeEnvironmentId: null
})
})
it('does not block creation for an unavailable path status', () => {
vi.useFakeTimers()
vi.setSystemTime(20_000)
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const cacheKey = useAppStore
.getState()
.getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null })
useAppStore.setState({
projectGroups: [projectGroup],
fetchFolderWorkspacePathStatus: vi.fn().mockResolvedValue(null),
folderWorkspacePathStatuses: {
[cacheKey]: {
status: {
path: '/workspace/platform',
exists: false,
reason: 'unavailable'
},
checkedAt: 20_000,
requestSnapshot: projectGroupRequestSnapshot
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<HookProbe />)
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(false)
})
it('blocks while refreshing after a cached blocking path status expires', async () => {
vi.useFakeTimers()
vi.setSystemTime(20_000)
const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id }
const cacheKey = useAppStore
.getState()
.getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null })
useAppStore.setState({
projectGroups: [projectGroup],
fetchFolderWorkspacePathStatus: vi.fn(),
folderWorkspacePathStatuses: {
[cacheKey]: {
status: {
path: '/workspace/platform',
exists: false,
reason: 'missing'
},
checkedAt: 20_000,
requestSnapshot: projectGroupRequestSnapshot
}
}
})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(<HookProbe />)
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(true)
act(() => {
vi.advanceTimersByTime(10_001)
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(true)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(
(
globalThis as {
__folderWorkspaceComposerPathStatusResult?: { pathStatusBlocksCreate: boolean }
}
).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate
).toBe(true)
})
})
@@ -2,9 +2,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makeProjectGroup, makeFolderWorkspace } from './folder-workspace-composer-submit-fixtures'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import type * as NewWorkspaceModule from '@/lib/new-workspace'
import { getDefaultSettings } from '../../../../../../shared/constants'
const mocks = vi.hoisted(() => ({
activateAndRevealFolderWorkspace: vi.fn(),
@@ -37,263 +36,6 @@ const linkedIssue = {
repoId: 'repo-1'
}
describe('submitFolderWorkspaceCreate', () => {
it.each(['claude', 'codex'] as const)(
'retains untouched ordinary %s Settings in the complete initial folder startup',
async (agent) => {
const previous = useAppStore.getState().settings
const raw = agent === 'claude' ? '--verbose' : '--search'
const model = agent === 'claude' ? 'opus' : 'gpt-5.4'
const settings = {
...getDefaultSettings('/test'),
agentDefaultSurface: 'terminal' as const,
agentPermissionDefault: 'own' as const,
agentDefaultArgs: { [agent]: raw },
agentLaunchChoices: { [agent]: { model, options: { effort: 'low' } } }
}
useAppStore.setState({ settings })
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
const onOpenChange = vi.fn()
try {
expect(
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: 'hi',
lastAutoName: '',
linkedWorkItem: null,
note: '',
quickAgent: agent,
autoRenameBranchFromWork: false,
settings,
agentCmdOverrides: {},
launchSource: 'new_workspace_composer',
createFolderWorkspace,
onOpenChange
})
).toBe(true)
const command =
agent === 'claude'
? "claude '--verbose' '--model' 'opus' '--effort' 'low'"
: `codex '-c' 'disable_paste_burst=true' '--search' '-m' 'gpt-5.4' '-c' 'model_reasoning_effort=low'`
expect(createFolderWorkspace.mock.calls).toEqual([
[
{
projectGroupId: 'group-1',
name: 'hi',
connectionId: null,
linkedTask: null,
createdWithAgent: agent
}
]
])
expect(mocks.activateAndRevealFolderWorkspace.mock.calls).toEqual([
[
'folder-workspace-1',
{
agent,
runtimeEnvironmentId: null,
startup: {
command,
env: {},
launchConfig: { agentCommand: `${agent} '${raw}'`, agentArgs: raw, agentEnv: {} },
launchToken: expect.stringMatching(/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/),
launchAgent: agent,
telemetry: {
agent_kind: agent === 'claude' ? 'claude-code' : 'codex',
launch_source: 'new_workspace_composer',
request_kind: 'new'
}
}
}
]
])
expect(onOpenChange.mock.calls).toEqual([[false]])
expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled()
} finally {
useAppStore.setState({ settings: previous })
}
}
)
beforeEach(() => {
mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: 'tab-1' })
})
afterEach(() => {
mocks.activateAndRevealFolderWorkspace.mockReset()
mocks.ensureAgentStartupInTerminal.mockReset()
vi.restoreAllMocks()
})
it('keeps linked Codex context out of submitted startup and pastes it as a draft', async () => {
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
const linkedWorkItem = {
provider: 'github' as const,
type: 'pr' as const,
number: 91,
title: 'Restore linked quick-create',
url: 'https://github.com/stablyai/orca/pull/91',
repoId: 'repo-1'
}
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: '',
lastAutoName: '',
linkedWorkItem,
note: 'Review this before starting',
quickAgent: 'codex',
autoRenameBranchFromWork: true,
agentCmdOverrides: {},
launchSource: 'new_workspace_composer',
createFolderWorkspace,
onOpenChange: vi.fn()
})
expect(createFolderWorkspace).toHaveBeenCalledWith({
projectGroupId: 'group-1',
name: 'Restore linked quick-create',
connectionId: null,
linkedTask: linkedWorkItem,
createdWithAgent: 'codex'
})
const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup
expect(startup?.command).toBe("codex '-c' 'disable_paste_burst=true'")
expect(startup?.command).not.toContain(linkedWorkItem.url)
expect(startup?.command).not.toContain('Review this before starting')
expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith({
worktreeId: folderWorkspaceKey('folder-workspace-1'),
primaryTabId: 'tab-1',
startup: expect.objectContaining({
agent: 'codex',
launchCommand: "codex '-c' 'disable_paste_burst=true'",
followupPrompt: null,
draftPrompt: `Review this before starting\n\n${linkedWorkItem.url}`
})
})
})
it('pastes the linked draft for remote Codex folder workspaces', async () => {
const createFolderWorkspace = vi.fn(async () =>
makeFolderWorkspace({
connectionId: 'ssh-1',
folderPath: '/home/alice/platform/Trust remote folder draft'
})
)
const linkedWorkItem = {
provider: 'github' as const,
type: 'pr' as const,
number: 92,
title: 'Trust remote folder draft',
url: 'https://github.com/stablyai/orca/pull/92',
repoId: 'repo-1'
}
const projectGroup = {
...makeProjectGroup(),
connectionId: 'ssh-1',
parentPath: '/home/alice/platform'
}
useAppStore.setState({
sshConnectionStates: new Map([
[
'ssh-1',
{
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0,
remotePlatform: 'linux'
}
]
])
})
await submitFolderWorkspaceCreate({
projectGroup,
name: '',
lastAutoName: '',
linkedWorkItem,
note: '',
quickAgent: 'codex',
autoRenameBranchFromWork: false,
agentCmdOverrides: {},
isRemote: true,
createFolderWorkspace,
onOpenChange: vi.fn()
})
expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith(
expect.objectContaining({
worktreeId: folderWorkspaceKey('folder-workspace-1'),
startup: expect.objectContaining({
agent: 'codex',
draftPrompt: linkedWorkItem.url
})
})
)
})
it('delivers non-linked follow-up prompts for agents that need stdin after launch', async () => {
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: 'Aider followup',
lastAutoName: '',
linkedWorkItem: null,
note: 'Fix the failing folder prompt flow',
quickAgent: 'aider',
autoRenameBranchFromWork: false,
agentCmdOverrides: {},
createFolderWorkspace,
onOpenChange: vi.fn()
})
const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup
expect(startup?.command).toBe('aider')
expect(mocks.ensureAgentStartupInTerminal).toHaveBeenCalledWith({
worktreeId: folderWorkspaceKey('folder-workspace-1'),
primaryTabId: 'tab-1',
startup: expect.objectContaining({
agent: 'aider',
launchCommand: 'aider',
followupPrompt: 'Fix the failing folder prompt flow'
})
})
})
it('uses native draft launch for linked agents with prefill support', async () => {
const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace())
const linkedWorkItem = {
provider: 'gitlab' as const,
type: 'mr' as const,
number: 17,
title: 'Review folder workspace draft',
url: 'https://gitlab.example.com/group/project/-/merge_requests/17',
repoId: 'repo-1'
}
await submitFolderWorkspaceCreate({
projectGroup: makeProjectGroup(),
name: '',
lastAutoName: '',
linkedWorkItem,
note: 'Check the migration path',
quickAgent: 'claude',
autoRenameBranchFromWork: true,
agentCmdOverrides: {},
createFolderWorkspace,
onOpenChange: vi.fn()
})
const startup = mocks.activateAndRevealFolderWorkspace.mock.calls[0]?.[1]?.startup
expect(startup?.command).toContain('claude --prefill')
expect(startup?.command).toContain('Check the migration path')
expect(startup?.command).toContain(linkedWorkItem.url)
expect(mocks.ensureAgentStartupInTerminal).not.toHaveBeenCalled()
})
})
describe('submitFolderWorkspaceCreate native-chat launch draft', () => {
function seededDraftFor(tabId: string): { text: string } | undefined {
return useAppStore.getState().nativeChatLaunchDraftByTabId[tabId]
@@ -349,35 +91,3 @@ describe('submitFolderWorkspaceCreate native-chat launch draft', () => {
expect(seededDraftFor('tab-1')?.text).toBe(ISSUE_URL)
})
})
it.each(['claude', 'codex'] as const)(
'rejects invalid requested %s startup before folder creation and preserves valid options',
async (agent) => {
const input = {
projectGroup: makeProjectGroup(),
name: 'configured',
lastAutoName: '',
linkedWorkItem: null,
note: 'fix',
quickAgent: agent,
launchChoice: { agent, route: 'terminal-tui' as const },
autoRenameBranchFromWork: false,
agentCmdOverrides: {},
agentArgs: '--add-dir "docs',
sessionOptions: { model: agent === 'claude' ? 'sonnet' : 'gpt-5.4' },
createFolderWorkspace: vi.fn(async () => makeFolderWorkspace()),
onOpenChange: vi.fn()
}
for (const linkedWorkItem of [null, linkedIssue]) {
await expect(submitFolderWorkspaceCreate({ ...input, linkedWorkItem })).rejects.toThrow(
'Check Arguments in this setup'
)
expect(input.createFolderWorkspace).not.toHaveBeenCalled()
expect(input.onOpenChange).not.toHaveBeenCalled()
}
expect(await submitFolderWorkspaceCreate({ ...input, agentArgs: '--add-dir docs' })).toBe(true)
const startup = mocks.activateAndRevealFolderWorkspace.mock.calls.at(-1)?.[1]?.startup
expect(startup.command).toMatch(/--add-dir.*docs/)
expect(startup.command).toContain(input.sessionOptions.model)
}
)
@@ -209,263 +209,13 @@ function sourceSegment(label: string, visibility: 'show' | 'hide'): HTMLButtonEl
const sourceSwitch = (label = 'Claude Code'): HTMLButtonElement => sourceSegment(label, 'show')
function sourceRow(label: string): HTMLElement {
const row = sourceSwitch(label).closest<HTMLElement>('[data-source-row]')
if (!row) {
throw new Error(`No ${label} source row`)
}
return row
}
function inheritEverythingFromGlobal(): void {
mocks.state.repos = [makeRepo({ externalWorktreeVisibility: undefined })]
mocks.state.settings = {
worktreeVisibilityDefaults: {
external: 'hide',
sourcePreferences: { builtIn: { claude: 'show' } }
}
}
}
async function click(element: HTMLElement): Promise<void> {
await act(async () => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
})
}
async function setInputValue(input: HTMLInputElement, value: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
})
}
describe('WorktreeVisibilityDialog', () => {
it('links directly to the global visibility defaults', async () => {
await renderDialog()
const manageGlobalSettings = buttonWithText('Manage in Global Settings')
expect(manageGlobalSettings.querySelector('.lucide-settings')).not.toBeNull()
await click(manageGlobalSettings)
expect(mocks.state.closeModal).toHaveBeenCalledOnce()
expect(mocks.state.openSettingsTarget).toHaveBeenCalledWith({
pane: 'general',
repoId: null,
sectionId: 'general-global-worktree-visibility'
})
expect(mocks.state.openSettingsPage).toHaveBeenCalledOnce()
})
it('lists a hidden agent worktree with a repo-relative path', async () => {
await renderDialog()
expect(document.body.textContent).toContain('Hidden worktrees (1)')
expect(document.body.textContent).toContain('Show one without enabling its source.')
expect(document.body.textContent).toContain('scratch-1')
expect(document.body.textContent).toContain('.claude/worktrees/scratch-1')
expect(document.body.textContent).not.toContain('/repo/.claude')
expect(
document.querySelector('button[aria-label="Which worktrees are hidden by default?"]')
).not.toBeNull()
})
it('bounds rendered rows for repositories with many hidden worktrees', async () => {
const worktrees = Array.from({ length: 500 }, (_, index) =>
makeWorktree({
id: `hidden-${index}`,
path: `/repo/.claude/worktrees/scratch-${index}`,
displayName: `scratch-${index}`
})
)
mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected(worktrees) }
await renderDialog()
expect(document.body.textContent).toContain('Hidden worktrees (500)')
expect(document.querySelectorAll('ul > li').length).toBeLessThan(20)
const search = document.querySelector<HTMLInputElement>(
'input[aria-label="Search hidden worktrees"]'
)
expect(search).not.toBeNull()
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
search,
'scratch-499'
)
search!.dispatchEvent(new Event('input', { bubbles: true }))
search!.dispatchEvent(new Event('change', { bubbles: true }))
})
expect(document.body.textContent).toContain('scratch-499')
expect(document.body.textContent).not.toContain('scratch-0')
})
it('gives each hidden worktree action a distinct accessible name', async () => {
mocks.state.detectedWorktreesByRepo = {
'repo-1': makeDetected([
makeWorktree({ displayName: 'scratch-a', path: '/repo/.claude/worktrees/scratch-a' }),
makeWorktree({ displayName: 'scratch-b', path: '/repo/.claude/worktrees/scratch-b' })
])
}
await renderDialog()
expect(
document.querySelector('button[aria-label="Show scratch-a at .claude/worktrees/scratch-a"]')
).not.toBeNull()
expect(
document.querySelector('button[aria-label="Show scratch-b at .claude/worktrees/scratch-b"]')
).not.toBeNull()
})
it('recovers a hidden worktree per path through the existing import exception', async () => {
await renderDialog()
await click(buttonWithText('Show'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
importedExternalWorktreePaths: [SCRATCH_PATH],
externalWorktreeInboxBaselinePaths: [SCRATCH_PATH]
})
expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1', {
requireAuthoritative: true
})
expect(mocks.state.closeModal).not.toHaveBeenCalled()
})
it('omits the hidden list when nothing is recoverable', async () => {
mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected([]) }
await renderDialog()
expect(document.body.textContent).toContain('Hidden worktrees (0)')
expect(document.body.textContent).toContain('No non-Orca worktrees found')
})
it('says it is checking instead of claiming nothing is hidden on a fallback snapshot', async () => {
mocks.state.detectedWorktreesByRepo = {
'repo-1': makeDetected([makeWorktree()], { authoritative: false, source: 'session-fallback' })
}
mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {}))
await renderDialog()
expect(document.body.textContent).toContain('Checking…')
expect(document.body.textContent).not.toContain('Hidden worktrees')
expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1', {
requireAuthoritative: true
})
})
it('offers a retry instead of a dead end when the list cannot be read', async () => {
mocks.state.detectedWorktreesByRepo = {
'repo-1': makeDetected([makeWorktree()], { authoritative: false, source: 'session-fallback' })
}
mocks.state.fetchWorktrees.mockResolvedValue(false)
await renderDialog()
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
"Could not list this repo's worktrees."
)
mocks.state.fetchWorktrees.mockImplementation(async () => {
mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected() }
return true
})
await click(buttonWithText('Try again'))
expect(document.querySelector('[role="alert"]')).toBeNull()
expect(document.body.textContent).toContain('Hidden worktrees')
})
it('keeps rows visible but not actionable until the open-time scan settles', async () => {
// Mid-scan writes must not reuse a pre-write confirmation fetch and falsely report successful recovery.
mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {}))
await renderDialog()
expect(document.body.textContent).toContain('scratch-1')
expect(buttonWithText('Show').disabled).toBe(true)
expect(document.body.textContent).toContain('Checking…')
})
it('locks the repo-wide toggle until the open-time scan settles', async () => {
// Post-write refresh must not coalesce onto scans started before the visibility write.
mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {}))
await renderDialog()
expect(sourceSwitch().disabled).toBe(true)
})
it('reports a failed refresh even while an older trusted snapshot is on screen', async () => {
// Warm snapshots need failure indicators when the host has since become unreachable.
mocks.state.fetchWorktrees.mockResolvedValue(false)
await renderDialog()
expect(document.body.textContent).toContain('scratch-1')
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
"Could not list this repo's worktrees."
)
})
it('locks retry while a row import is in flight, so it cannot race the write', async () => {
// Retry must not start a pre-import scan that absorbs the import’s confirmation refresh.
mocks.state.fetchWorktrees.mockResolvedValue(false)
mocks.state.updateRepo.mockImplementation(() => new Promise(() => {}))
await renderDialog()
await click(buttonWithText('Show'))
expect(buttonWithText('Try again').disabled).toBe(true)
})
it('locks the repo-wide toggle while a row import is in flight', async () => {
mocks.state.fetchWorktrees.mockResolvedValue(false)
mocks.state.updateRepo.mockImplementation(() => new Promise(() => {}))
await renderDialog()
await click(buttonWithText('Show'))
expect(sourceSwitch().disabled).toBe(true)
})
it('locks row actions and retry while the repo-wide toggle is in flight', async () => {
mocks.state.fetchWorktrees.mockResolvedValueOnce(false)
mocks.state.updateRepo.mockImplementation(() => new Promise(() => {}))
await renderDialog()
await click(sourceSwitch())
expect(buttonWithText('Show').disabled).toBe(true)
expect(buttonWithText('Try again').disabled).toBe(true)
})
it('clears a stale failure once a row import refreshes the list successfully', async () => {
mocks.state.fetchWorktrees.mockResolvedValueOnce(false)
await renderDialog()
expect(document.querySelector('[role="alert"]')).not.toBeNull()
mocks.state.fetchWorktrees.mockResolvedValue(true)
await click(buttonWithText('Show'))
expect(document.querySelector('[role="alert"]')).toBeNull()
})
it('does not leak an old repo action failure into a newly opened repo', async () => {
const update = deferred<boolean>()
mocks.state.updateRepo.mockReturnValueOnce(update.promise)
await renderDialog()
await click(buttonWithText('Show'))
mocks.state.modalData = { repoId: 'repo-2' }
mocks.state.repos = [makeRepo({ id: 'repo-2', path: '/repo-2', displayName: 'other' })]
mocks.state.detectedWorktreesByRepo = { 'repo-2': makeDetected([], { repoId: 'repo-2' }) }
await renderDialog()
update.resolve(false)
await act(async () => update.promise)
expect(document.querySelector('[role="alert"]')).toBeNull()
})
it('keeps a remounted repo locked until its earlier row action settles', async () => {
const update = deferred<boolean>()
mocks.state.updateRepo.mockReturnValueOnce(update.promise)
@@ -561,19 +311,6 @@ describe('WorktreeVisibilityDialog', () => {
})
})
it('reports a failed persistent visibility update without starting a refresh', async () => {
mocks.state.updateRepo.mockResolvedValue(false)
await renderDialog()
mocks.state.fetchWorktrees.mockClear()
await click(sourceSwitch())
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Could not update worktree visibility. Try again.'
)
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
it('does not report success when an older host strips additive source settings (STA-4092)', async () => {
mocks.state.updateRepo.mockResolvedValue(true)
await renderDialog()
@@ -588,277 +325,6 @@ describe('WorktreeVisibilityDialog', () => {
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
it('reports a failed authoritative refresh after updating persistent visibility', async () => {
await renderDialog()
mocks.state.fetchWorktrees.mockResolvedValue(false)
await click(sourceSwitch())
expect(mocks.state.fetchWorktrees).toHaveBeenLastCalledWith('repo-1', {
requireAuthoritative: true
})
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
"Could not list this repo's worktrees."
)
})
it('toggles built-in sources independently', async () => {
await renderDialog()
expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('false')
expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('false')
await click(sourceSwitch('Claude Code'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show' }
}
})
expect(mocks.state.closeModal).not.toHaveBeenCalled()
})
it('shows when source visibility is inherited from the global default', async () => {
inheritEverythingFromGlobal()
await renderDialog()
expect(sourceRow('Claude Code').textContent).not.toContain('Overriding global setting')
expect(sourceRow('GSD').textContent).not.toContain('Overriding global setting')
expect(sourceRow('Other locations').textContent).not.toContain('Overriding global setting')
expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('true')
expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('false')
})
it('lists what each inheritable source is set to in global settings', async () => {
inheritEverythingFromGlobal()
await renderDialog()
const intro = [...document.querySelectorAll('p')].find(
(candidate) =>
candidate.textContent === 'These sources have a global setting you can override here:'
)
expect(intro).not.toBeUndefined()
expect(
[...(intro?.nextElementSibling?.querySelectorAll('li') ?? [])].map((item) => item.textContent)
).toEqual(['Claude CodeShow', 'GSDHide', 'Other locationsHide'])
expect(buttonWithText('Manage in Global Settings')).not.toBeNull()
})
it('names the global value an override is ignoring and reverts on the same control', async () => {
mocks.state.repos = [
makeRepo({
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'show' }
}
})
]
mocks.state.settings = {
worktreeVisibilityDefaults: {
external: 'hide',
sourcePreferences: { builtIn: { claude: 'hide', gsd: 'hide' } }
}
}
await renderDialog()
expect(sourceRow('Claude Code').textContent).toContain('Overriding global setting: Hide')
await click(sourceSegment('Claude Code', 'hide'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
agentWorktreeVisibility: null,
worktreeVisibilitySourcePreferences: { builtIn: { gsd: 'show' } }
})
})
it('materializes the remaining legacy source override when one source reverts to global', async () => {
mocks.state.repos = [makeRepo({ agentWorktreeVisibility: 'show' })]
mocks.state.settings = { worktreeVisibilityDefaults: { external: 'hide' } }
await renderDialog()
await click(sourceSegment('Claude Code', 'hide'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
agentWorktreeVisibility: null,
worktreeVisibilitySourcePreferences: { builtIn: { gsd: 'show' } }
})
})
it('migrates legacy Always show to both built-in source rows', async () => {
mocks.state.repos = [makeRepo({ agentWorktreeVisibility: 'show' })]
// Global must also show so hiding creates an override rather than reverting to inheritance.
mocks.state.settings = {
worktreeVisibilityDefaults: {
external: 'hide',
sourcePreferences: { builtIn: { claude: 'show', gsd: 'show' } }
}
}
await renderDialog()
expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('true')
expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('true')
await click(sourceSegment('Claude Code', 'hide'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'hide', gsd: 'show' }
}
})
})
it('keeps ordinary non-Orca visibility on its own source row', async () => {
mocks.state.repos = [makeRepo({ externalWorktreeVisibility: 'show' })]
// Global must also show so hiding creates an override rather than reverting to inheritance.
mocks.state.settings = { worktreeVisibilityDefaults: { external: 'show' } }
await renderDialog()
expect(sourceSwitch('Other locations').getAttribute('aria-checked')).toBe('true')
await click(sourceSegment('Other locations', 'hide'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
externalWorktreeVisibility: 'hide'
})
})
it('adds custom locations disabled by default', async () => {
await renderDialog()
const input = document.querySelector<HTMLInputElement>('#custom-worktree-root')
expect(input).not.toBeNull()
expect(
document.querySelector('section[aria-labelledby="worktree-sources-heading"]')?.contains(input)
).toBe(true)
expect(input?.closest('form')?.getAttribute('aria-label')).toBe('Add location')
expect(input?.closest('form')?.textContent).not.toContain('Add location')
await setInputValue(input!, '/srv/team-worktrees')
await click(buttonWithText('Add'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith(
'repo-1',
expect.objectContaining({
customWorktreeVisibilitySources: [
expect.objectContaining({ rootPath: '/srv/team-worktrees' })
],
worktreeVisibilitySourcePreferences: expect.objectContaining({
custom: expect.objectContaining({})
})
})
)
const update = mocks.state.updateRepo.mock.calls.at(-1)?.[1] as {
customWorktreeVisibilitySources: { id: string }[]
worktreeVisibilitySourcePreferences: { custom: Record<string, string> }
}
expect(
update.worktreeVisibilitySourcePreferences.custom[
update.customWorktreeVisibilitySources[0]!.id
]
).toBe('hide')
})
it('keeps source matching stable while typing in the inline form', async () => {
mocks.state.repos = [
makeRepo({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }]
})
]
await renderDialog()
const normalize = vi.spyOn(String.prototype, 'normalize')
await setInputValue(
document.querySelector<HTMLInputElement>('#custom-worktree-root')!,
'/srv/x'
)
expect(normalize).not.toHaveBeenCalled()
normalize.mockRestore()
})
it('distinguishes invalid, duplicate, limit, and save failures when adding a source', async () => {
const existing = Array.from({ length: 32 }, (_, index) => ({
id: `source-${index}`,
rootPath: `/srv/source-${index}`
}))
mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing })]
await renderDialog()
const input = document.querySelector<HTMLInputElement>('#custom-worktree-root')!
await setInputValue(input, '/srv/new')
await click(buttonWithText('Add'))
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Remove a custom location before adding another.'
)
expect(mocks.state.updateRepo).not.toHaveBeenCalled()
mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing.slice(0, 1) })]
await act(async () => root.render(null))
await renderDialog()
const duplicateInput = document.querySelector<HTMLInputElement>('#custom-worktree-root')!
await setInputValue(duplicateInput, '/srv/source-0')
await click(buttonWithText('Add'))
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'This location is already listed.'
)
await setInputValue(duplicateInput, 'relative/path')
await click(buttonWithText('Add'))
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Enter an absolute path for this host.'
)
mocks.state.updateRepo.mockResolvedValue(false)
await setInputValue(duplicateInput, '/srv/valid')
await click(buttonWithText('Add'))
const alerts = [...document.querySelectorAll('[role="alert"]')]
expect(alerts).toHaveLength(1)
expect(alerts[0]?.textContent).toContain('Could not update worktree visibility. Try again.')
expect(alerts[0]?.textContent).not.toContain('Enter an absolute path')
})
it('reports only unsupported-host copy when an older host strips an added source', async () => {
mocks.state.updateRepo.mockResolvedValue(true)
await renderDialog()
mocks.state.fetchWorktrees.mockClear()
const input = document.querySelector<HTMLInputElement>('#custom-worktree-root')!
await setInputValue(input, '/srv/valid')
await click(buttonWithText('Add'))
const alerts = [...document.querySelectorAll('[role="alert"]')]
expect(alerts).toHaveLength(1)
expect(alerts[0]?.textContent).toContain(
"This host doesn't support source-specific worktree visibility."
)
expect(alerts[0]?.textContent).not.toContain('Enter an absolute path')
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
it('removes custom locations without changing other source preferences', async () => {
mocks.state.repos = [
makeRepo({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
worktreeVisibilitySourcePreferences: { custom: { team: 'hide' } }
})
]
await renderDialog()
expect(sourceSwitch('/srv/team-worktrees').getAttribute('aria-checked')).toBe('false')
const remove = document.querySelector<HTMLButtonElement>(
'button[aria-label="Remove /srv/team-worktrees"]'
)
expect(remove).not.toBeNull()
await click(remove!)
expect(mocks.state.updateRepo).toHaveBeenCalledWith(
'repo-1',
expect.objectContaining({
customWorktreeVisibilitySources: [],
worktreeVisibilitySourcePreferences: expect.not.objectContaining({
custom: expect.objectContaining({ team: expect.anything() })
})
})
)
expect(mocks.state.closeModal).not.toHaveBeenCalled()
})
it('rejects removal success when an older host leaves the custom preference behind', async () => {
const repo = makeRepo({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
@@ -879,70 +345,4 @@ describe('WorktreeVisibilityDialog', () => {
)
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
it('uses full paths to distinguish custom source controls with the same basename', async () => {
mocks.state.repos = [
makeRepo({
customWorktreeVisibilitySources: [
{ id: 'alpha', rootPath: '/srv/alpha/team' },
{ id: 'beta', rootPath: '/srv/beta/team' }
]
})
]
await renderDialog()
expect(sourceSwitch('/srv/alpha/team')).not.toBeNull()
expect(sourceSwitch('/srv/beta/team')).not.toBeNull()
expect(document.querySelector('button[aria-label="Remove /srv/alpha/team"]')).not.toBeNull()
expect(document.querySelector('button[aria-label="Remove /srv/beta/team"]')).not.toBeNull()
})
it('shows inherited global locations without repository removal controls', async () => {
mocks.state.settings = {
worktreeVisibilityDefaults: {
external: 'hide',
customSources: [{ id: 'global-team', rootPath: '/srv/global-team' }],
sourcePreferences: { custom: { 'global-team': 'show' } }
}
}
await renderDialog()
expect(sourceSwitch('/srv/global-team').getAttribute('aria-checked')).toBe('true')
expect(document.querySelector('button[aria-label="Remove /srv/global-team"]')).toBeNull()
await click(sourceSegment('/srv/global-team', 'hide'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
worktreeVisibilitySourcePreferences: { custom: { 'global-team': 'hide' } }
})
})
it('resets global custom and Other locations without changing their global defaults', async () => {
mocks.state.repos = [
makeRepo({
externalWorktreeVisibility: 'show',
worktreeVisibilitySourcePreferences: { custom: { 'global-team': 'hide' } }
})
]
mocks.state.settings = {
worktreeVisibilityDefaults: {
external: 'hide',
customSources: [{ id: 'global-team', rootPath: '/srv/global-team' }],
sourcePreferences: { custom: { 'global-team': 'show' } }
}
}
await renderDialog()
expect(sourceRow('/srv/global-team').textContent).toContain('Overriding global setting: Show')
expect(sourceRow('Other locations').textContent).toContain('Overriding global setting: Hide')
await click(sourceSegment('/srv/global-team', 'show'))
await click(sourceSegment('Other locations', 'hide'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
worktreeVisibilitySourcePreferences: {}
})
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
externalWorktreeVisibility: null
})
})
})
@@ -1,361 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getExecutionHostLabel } from '../../../../../../shared/execution-host'
import {
buildSidebarHostOptions,
buildSidebarHostScopeOptions,
getSidebarHostVisibilityLabel,
getSidebarHostHealthLabel,
shouldShowHostScopeControls,
type SidebarHostOption
} from './sidebar-host-options'
import { toggleVisibleHostRow } from '../../../../../../shared/workspace-view/workspace-host-visibility'
import { filterFolderWorkspacesForVisibleHosts } from '../../../../../../shared/sidebar-list/host-filtering'
import type { FolderWorkspace } from '../../../../../../shared/folder-workspaces/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { SshConnectionState } from '../../../../../../shared/ssh-types'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
describe('sidebar host options', () => {
it('hides host controls for local-only workspaces', () => {
const hosts = buildSidebarHostOptions({
repos: [{ connectionId: null }],
sshTargetLabels: new Map(),
settings: { activeRuntimeEnvironmentId: null }
})
expect(hosts).toEqual([
{
id: 'local',
label: LOCAL_HOST_LABEL,
detail: 'This computer',
kind: 'local',
health: 'local',
presence: 'local'
}
])
expect(shouldShowHostScopeControls(hosts)).toBe(false)
})
it('includes SSH hosts from labels and repos', () => {
const hosts = buildSidebarHostOptions({
repos: [{ connectionId: 'ssh-from-repo' }],
sshTargetLabels: new Map([['ssh-saved', 'Saved SSH']]),
settings: { activeRuntimeEnvironmentId: null }
})
expect(hosts.map((host) => host.id)).toEqual(['local', 'ssh:ssh-saved', 'ssh:ssh-from-repo'])
expect(hosts.map((host) => host.health)).toEqual(['local', 'disconnected', 'disconnected'])
expect(hosts.find((host) => host.id === 'ssh:ssh-saved')?.presence).toBe('configured')
expect(hosts.find((host) => host.id === 'ssh:ssh-from-repo')?.presence).toBe('project')
expect(shouldShowHostScopeControls(hosts)).toBe(true)
})
it('includes SSH health in options', () => {
const hosts = buildSidebarHostOptions({
repos: [{ connectionId: 'ssh-1' }],
sshTargetLabels: new Map([['ssh-1', 'Builder']]),
sshConnectionStates: new Map([
[
'ssh-1',
{
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
}
]
]),
settings: { activeRuntimeEnvironmentId: null }
})
expect(hosts.find((host) => host.id === 'ssh:ssh-1')).toMatchObject({
label: 'Builder',
health: 'available'
})
})
it('says why an SSH host failed, as Settings does (#29648)', () => {
const failed = (state: Pick<SshConnectionState, 'status'> & Partial<SshConnectionState>) =>
buildSidebarHostOptions({
repos: [],
sshTargetLabels: new Map([['ssh-1', 'Builder']]),
sshConnectionStates: new Map([
['ssh-1', { targetId: 'ssh-1', error: null, reconnectAttempt: 0, ...state }]
]),
settings: { activeRuntimeEnvironmentId: null }
}).find((host) => host.id === 'ssh:ssh-1')
expect(
failed({ status: 'auth-failed', error: 'All configured authentication methods failed' })
).toMatchObject({
health: 'error',
detail: 'SSH',
failureReason: 'All configured authentication methods failed'
})
expect(
failed({
status: 'error',
error: 'raw',
managedServer: {
kind: 'relay',
reason: 'refused',
detail: 'This SSH host cannot move yet: automations (1).'
}
})?.failureReason
).toContain('automations (1)')
expect(failed({ status: 'connected' })).not.toHaveProperty('failureReason')
})
it('includes the focused runtime compatibility host', () => {
const hosts = buildSidebarHostOptions({
repos: [],
sshTargetLabels: new Map(),
settings: { activeRuntimeEnvironmentId: 'runtime-1' }
})
expect(hosts.map((host) => host.id)).toEqual(['local', 'runtime:runtime-1'])
// A first probe still in progress is not evidence of disconnection.
expect(hosts.find((host) => host.id === 'runtime:runtime-1')).toMatchObject({
detail: 'Orca server',
health: 'connecting'
})
})
it('uses saved runtime environment names for runtime host labels', () => {
const hosts = buildSidebarHostOptions({
repos: [],
sshTargetLabels: new Map(),
settings: { activeRuntimeEnvironmentId: '03ef704c-b180-4b10-998d-e28fbd5de9a3' },
runtimeEnvironments: [
{
id: '03ef704c-b180-4b10-998d-e28fbd5de9a3',
name: 'dev box'
}
]
})
expect(hosts.find((host) => host.id.startsWith('runtime:'))).toMatchObject({
label: 'dev box',
detail: 'Orca server'
})
})
it('marks a runtime host blocked when its live status fails compat', () => {
const hosts = buildSidebarHostOptions({
repos: [],
sshTargetLabels: new Map(),
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
// Protocol zero must produce a blocked server-too-old verdict from the live status map.
runtimeStatusByEnvironmentId: new Map([
[
'runtime-1',
{
checkedAt: 0,
status: {
runtimeId: 'rt',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: 0,
minCompatibleRuntimeClientVersion: 0
}
}
]
])
})
const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1')
expect(runtimeHost?.health).toBe('blocked')
expect(runtimeHost?.compatibility).toMatchObject({
kind: 'blocked',
reason: 'server-too-old'
})
})
it('leaves a runtime host available when its live status is compatible', () => {
const hosts = buildSidebarHostOptions({
repos: [],
sshTargetLabels: new Map(),
settings: { activeRuntimeEnvironmentId: 'runtime-1' },
runtimeStatusByEnvironmentId: new Map([
[
'runtime-1',
{
checkedAt: 0,
status: {
runtimeId: 'rt',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 3
}
}
]
])
})
const runtimeHost = hosts.find((host) => host.id === 'runtime:runtime-1')
expect(runtimeHost?.health).toBe('available')
expect(runtimeHost?.compatibility?.kind).toBe('ok')
})
it('builds all-host plus focused-host scope options', () => {
const hosts = buildSidebarHostOptions({
repos: [{ connectionId: 'ssh-1' }],
sshTargetLabels: new Map([['ssh-1', 'Builder']]),
settings: { activeRuntimeEnvironmentId: null }
})
expect(buildSidebarHostScopeOptions(hosts)).toMatchObject([
{ id: 'all', label: 'All hosts', detail: `${LOCAL_HOST_LABEL}, Builder`, health: 'mixed' },
{ id: 'local', label: LOCAL_HOST_LABEL, health: 'local' },
{ id: 'ssh:ssh-1', label: 'Builder', health: 'disconnected' }
])
})
it('labels visible host selections for the workspace options menu', () => {
const hosts = buildSidebarHostOptions({
repos: [{ connectionId: 'ssh-1' }],
sshTargetLabels: new Map([['ssh-1', 'Builder']]),
settings: { activeRuntimeEnvironmentId: null }
})
expect(getSidebarHostVisibilityLabel(null, hosts)).toBe('All hosts')
expect(getSidebarHostVisibilityLabel(['ssh:ssh-1'], hosts)).toBe('Builder')
expect(getSidebarHostVisibilityLabel(['local', 'ssh:ssh-1'], hosts)).toBe('All hosts')
})
it('carries host kind so the header menu can pick lifecycle actions', () => {
const hosts = buildSidebarHostOptions({
repos: [{ connectionId: 'ssh-1' }],
sshTargetLabels: new Map([['ssh-1', 'Builder']]),
settings: { activeRuntimeEnvironmentId: 'runtime-1' }
})
expect(hosts.find((host) => host.id === 'local')?.kind).toBe('local')
expect(hosts.find((host) => host.id === 'ssh:ssh-1')?.kind).toBe('ssh')
expect(hosts.find((host) => host.id === 'runtime:runtime-1')?.kind).toBe('runtime')
})
it('labels host health for compact sidebar UI', () => {
expect(getSidebarHostHealthLabel('available')).toBe('Connected')
expect(getSidebarHostHealthLabel('connecting')).toBe('Connecting')
expect(getSidebarHostHealthLabel('blocked')).toBe('Update needed')
expect(getSidebarHostHealthLabel('blocked', 'incompatible')).toBe('Update needed')
expect(getSidebarHostHealthLabel('blocked', 'access-revoked')).toBe('Re-pair needed')
expect(getSidebarHostHealthLabel('error')).toBe('Needs attention')
})
it('names a host scope saved under either merged id after the merged host', () => {
const hosts = mergedHosts()
expect(hosts.map((host) => host.id)).toEqual([
'local',
'runtime:omarchy-server',
'ssh:omarchy-target'
])
expect(getSidebarHostVisibilityLabel(['ssh:omarchy-target'], hosts)).toBe('Omarchy')
expect(getSidebarHostVisibilityLabel(['runtime:omarchy-server'], hosts)).toBe('Omarchy')
expect(buildSidebarHostScopeOptions(hosts).map((option) => option.id)).toEqual([
'all',
'local',
'runtime:omarchy-server'
])
})
it('keeps folder-only workspaces of the merged-away id when the merged host is chosen', () => {
const hosts = mergedHosts({ kind: 'relay', reason: 'source_changed' })
const shown = hosts.find((host) => host.id === 'ssh:omarchy-target')
expect(shown?.aliasHostIds).toEqual(['runtime:omarchy-server'])
const visible = toggleVisibleHostRow(hosts, null, shown!)
const group = projectGroup('runtime:omarchy-server')
expect(new Set(visible)).toEqual(new Set(['ssh:omarchy-target', 'runtime:omarchy-server']))
expect(
filterFolderWorkspacesForVisibleHosts(
[folderWorkspace(group.id)],
[group],
new Set(visible),
'local'
)
).toHaveLength(1)
// Unchecking the row removes both ids, and all rows checked collapses to every host.
expect(toggleVisibleHostRow(hosts, ['local', ...visible!], shown!)).toEqual(['local'])
expect(toggleVisibleHostRow(hosts, ['local'], shown!)).toBeNull()
})
})
function mergedHosts(managedServer?: SshConnectionState['managedServer']): SidebarHostOption[] {
return buildSidebarHostOptions({
repos: [],
sshTargetLabels: new Map([['omarchy-target', 'Omarchy']]),
sshConnectionStates: managedServer
? new Map([
[
'omarchy-target',
{
targetId: 'omarchy-target',
status: 'connected',
error: null,
reconnectAttempt: 0,
managedServer
}
]
])
: undefined,
settings: null,
runtimeEnvironments: [
{
id: 'omarchy-server',
name: 'Omarchy',
orcadDeployment: {
sshTargetId: 'omarchy-target',
sshTargetGeneration: 1,
localPort: 7001,
remotePort: 7002
}
}
]
})
}
function projectGroup(executionHostId: string): ProjectGroup {
return {
id: 'group-1',
name: 'Notes',
parentPath: null,
executionHostId,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
}
function folderWorkspace(projectGroupId: string): FolderWorkspace {
return {
id: 'folder-1',
projectGroupId,
name: 'notes',
folderPath: '/notes',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
}
@@ -4,7 +4,7 @@ import { useWorktreeDragRuntime } from './use-runtime'
import { useWorktreePointerDragWindowEvents } from './use-pointer-window-events'
import { commitWorktreePointerDrop } from './pointer-commit'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushWorktreePointerDragFrame, type WorktreePointerDragFrameArgs } from './pointer-flush'
import type { WorktreePointerDragFrameArgs } from './pointer-flush'
import { NO_WORKTREE_SIDEBAR_DROP_TARGET, WORKTREE_ROW_DRAG_INITIAL_STATE } from './row-state'
vi.mock('../../workspace-board/drag/workspace-kanban-sidebar-drop', () => ({
@@ -107,91 +107,6 @@ function setup() {
}
}
describe('combined nesting and animated reordering', () => {
it('lets the pointer cross an edge into nesting without moving the destination', () => {
const t = setup()
flushWorktreePointerDragFrame(t.args)
expect(t.state().previewOffsetsByWorktreeId.size).toBe(0)
t.nest('parent')
t.tick(80)
expect(t.state().lineageDropTargetId).toBe('parent')
expect(t.state().previewOffsetsByWorktreeId.size).toBe(0)
t.tick(200)
expect(t.state().lineageDropTargetId).toBe('parent')
expect(t.state().dropIndicatorY).toBeNull()
})
it('opens the reorder gap, holds it during nesting, then restores the edge preview', () => {
const t = setup()
flushWorktreePointerDragFrame(t.args)
t.tick(160)
expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets)
expect(t.state().dropIndicatorY).toBe(300)
t.nest('parent')
flushWorktreePointerDragFrame(t.args)
expect(t.state().lineageDropTargetId).toBe('parent')
expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets)
expect(t.state().dropIndicatorY).toBeNull()
t.nest(null)
flushWorktreePointerDragFrame(t.args)
t.tick(160)
expect(t.state().lineageDropTargetId).toBeNull()
expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets)
expect(t.state().dropIndicatorY).toBe(300)
})
it('clears the old line during a new intent and stops scheduling after settling', () => {
const t = setup()
flushWorktreePointerDragFrame(t.args)
t.tick(160)
expect(t.state().dropIndicatorY).toBe(300)
t.args.drag.currentY = 356
t.args.ctx.computeWorktreeDrop = () => ({
dropIndex: 2,
dropIndicatorY: 356,
dropAnchorId: null,
previewOffsetsByWorktreeId: t.offsets
})
flushWorktreePointerDragFrame(t.args)
expect(t.state().dropIndicatorY).toBeNull()
expect(t.state().previewOffsetsByWorktreeId).toBe(t.offsets)
t.tick(160)
expect(t.state().dropIndicatorY).toBe(356)
const frames = vi.mocked(window.requestAnimationFrame).mock.calls.length
t.tick(1000)
expect(vi.mocked(window.requestAnimationFrame).mock.calls).toHaveLength(frames)
})
})
describe('stationary pointer autoscroll', () => {
it.each([1, -1])('keeps the gap tracking slots while scrolling in direction %s', (direction) => {
const t = setup()
const container = document.createElement('div')
t.args.ctx.scrollRef.current = container
let index = 10
let offsets = new Map([['parent', 56]])
t.args.ctx.computeWorktreeDrop = () => ({
dropIndex: index,
dropIndicatorY: 300 - container.scrollTop,
dropAnchorId: null,
previewOffsetsByWorktreeId: offsets
})
flushWorktreePointerDragFrame(t.args)
for (let frame = 1; frame <= 6; frame++) {
index += direction
container.scrollTop += direction * 56
offsets = new Map([[`row-${index}`, direction * 56]])
t.tick(80)
flushWorktreePointerDragFrame(t.args)
if (frame >= 2) {
expect(t.state().previewOffsetsByWorktreeId).toBe(offsets)
expect(t.state().dropIndicatorY).toBe(300 - container.scrollTop)
}
}
expect(t.args.drag.currentY).toBe(300)
})
})
describe('Escape during pointer dragging', () => {
function renderDrag() {
const t = setup()
@@ -237,19 +152,6 @@ describe('Escape during pointer dragging', () => {
expect(t.result.current.worktreeDragState).toBe(WORKTREE_ROW_DRAG_INITIAL_STATE)
})
it('leaves other keys and Escape without a drag available to the app', () => {
const t = renderDrag()
const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
window.dispatchEvent(enter)
expect(enter.defaultPrevented).toBe(false)
expect(t.result.current.worktreePointerDragRef.current).toBe(t.args.drag)
act(() => t.result.current.clearWorktreeDrag())
const escape = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })
window.dispatchEvent(escape)
expect(escape.defaultPrevented).toBe(false)
expect(t.cancelBoard).toHaveBeenCalledOnce()
})
it('removes its Escape listener on unmount', () => {
const t = renderDrag()
t.unmount()
@@ -1,426 +0,0 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { getLogicalRepoOrderRankById } from '../../../../../../shared/project-section-order'
import {
applyAllRepoInsertAt,
computeProjectHeaderDropPreview,
getProjectGroupOrderForSidebarDrop,
getProjectHeaderDragBucketKey,
getSidebarOrderedRepoHeaderIdsByBucket,
mapSidebarProjectHeaderDropIndexToSiblingInsertIndex,
mapSidebarRepoDropIndexToAllRepoInsertAt
} from './project-header-drop'
import type { Row } from '../../../../../../shared/sidebar-list/row-types'
import type { Repo } from '../../../../../../shared/repo-types'
describe('getProjectHeaderDragBucketKey', () => {
it('uses ungrouped for repos without a project group', () => {
expect(getProjectHeaderDragBucketKey({ projectGroupId: undefined })).toBe('ungrouped')
})
it('scopes grouped repos to their project group bucket', () => {
expect(getProjectHeaderDragBucketKey({ projectGroupId: 'group-a' })).toBe('group:group-a')
})
})
describe('getSidebarOrderedRepoHeaderIdsByBucket', () => {
it('groups repo headers by project group membership', () => {
const rows = [
{
type: 'header',
key: 'repo:a',
label: 'A',
count: 1,
repo: { id: 'a', projectGroupId: 'group-a' }
},
{
type: 'header',
key: 'repo:b',
label: 'B',
count: 1,
repo: { id: 'b' }
}
] as Row[]
expect(getSidebarOrderedRepoHeaderIdsByBucket(rows)).toEqual(
new Map([
['group:group-a', ['a']],
['ungrouped', ['b']]
])
)
})
it("orders a host copy by its project's anchor and skips copies already drawn", () => {
const repo = (id: string): Repo => ({
id,
path: `/${id}`,
displayName: id,
badgeColor: '#000000',
addedAt: 0
})
const header = (key: string, fields: Partial<Extract<Row, { type: 'header' }>>): Row => ({
type: 'header',
key,
count: 1,
icon: { kind: 'project' },
...fields
})
const rows = [
header('project:p', { repo: repo('p-ssh'), anchorRepo: repo('p-local'), hostId: 'ssh:b' }),
header('project:q', { repo: repo('q'), hostId: 'ssh:b' }),
header('project:p', {
repo: repo('p-local'),
anchorRepo: repo('p-local'),
hostId: 'local',
repeated: true
})
]
expect(getSidebarOrderedRepoHeaderIdsByBucket(rows)).toEqual(
new Map([['ungrouped', ['p-local', 'q']]])
)
})
})
describe('getLogicalRepoOrderRankById', () => {
it('anchors a merged paired-host header to its first persisted occurrence', () => {
const rankById = getLogicalRepoOrderRankById(['b', 'same', 'c', 'same'])
expect(rankById).toEqual(
new Map([
['b', 0],
['same', 1],
['c', 2]
])
)
})
})
describe('mapSidebarRepoDropIndexToAllRepoInsertAt', () => {
const sidebar = ['a', 'b', 'c']
it('maps sidebar start drops onto the first visible repo in the full list', () => {
expect(mapSidebarRepoDropIndexToAllRepoInsertAt(0, sidebar, ['hidden', 'a', 'b', 'c'])).toBe(1)
})
it('maps sidebar end drops onto the slot after the last visible repo', () => {
expect(mapSidebarRepoDropIndexToAllRepoInsertAt(3, sidebar, ['a', 'hidden', 'b', 'c'])).toBe(4)
})
it('maps middle sidebar drops onto the target repo id in the full list', () => {
expect(mapSidebarRepoDropIndexToAllRepoInsertAt(2, sidebar, ['a', 'hidden', 'b', 'c'])).toBe(3)
})
})
describe('mapSidebarProjectHeaderDropIndexToSiblingInsertIndex', () => {
it('keeps upward drops at the same target index after removing the source', () => {
expect(
mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({
sidebarDropIndex: 0,
sourceIndex: 2,
siblingCount: 2
})
).toBe(0)
})
it('shifts downward drops because the source header is removed first', () => {
expect(
mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({
sidebarDropIndex: 3,
sourceIndex: 0,
siblingCount: 2
})
).toBe(2)
})
it('maps a drop immediately after the source back to the original slot', () => {
expect(
mapSidebarProjectHeaderDropIndexToSiblingInsertIndex({
sidebarDropIndex: 2,
sourceIndex: 1,
siblingCount: 2
})
).toBe(1)
})
})
describe('computeProjectHeaderDropPreview', () => {
it('uses row-model header indices instead of mounted subset order', () => {
const preview = computeProjectHeaderDropPreview({
pointerY: 105,
containerTop: 0,
scrollTop: 0,
sidebarRepoHeaderIds: ['a', 'b', 'c', 'd', 'e'],
rects: [
{ repoId: 'b', bucketKey: 'ungrouped', headerIndex: 1, top: 100, bottom: 128 },
{ repoId: 'c', bucketKey: 'ungrouped', headerIndex: 2, top: 200, bottom: 228 },
{ repoId: 'd', bucketKey: 'ungrouped', headerIndex: 3, top: 300, bottom: 328 }
]
})
expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: 96 })
})
it('supports boundary drops at the end of the full sidebar list', () => {
const preview = computeProjectHeaderDropPreview({
pointerY: 400,
containerTop: 0,
scrollTop: 0,
sidebarRepoHeaderIds: ['a', 'b', 'c'],
rects: [
{
repoId: 'c',
bucketKey: 'ungrouped',
headerIndex: 2,
top: 300,
bottom: 328,
sectionBottom: 380
}
]
})
expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: 383 })
})
it('snaps a drop inside the last expanded project section to its bottom boundary', () => {
const INDICATOR_GAP = 4
const sectionBottom = 380
const preview = computeProjectHeaderDropPreview({
pointerY: 350,
containerTop: 0,
scrollTop: 0,
sidebarRepoHeaderIds: ['a', 'b', 'c'],
rects: [
{
repoId: 'c',
bucketKey: 'ungrouped',
headerIndex: 2,
top: 300,
bottom: 328,
sectionBottom
}
]
})
expect(preview).toEqual({ dropIndex: 3, dropIndicatorY: sectionBottom + INDICATOR_GAP })
})
it('snaps a drop between sibling project headers to the nearer boundary', () => {
const INDICATOR_GAP = 4
const nextHeaderTop = 220
const preview = computeProjectHeaderDropPreview({
pointerY: 150,
containerTop: 0,
scrollTop: 0,
sidebarRepoHeaderIds: ['a', 'b'],
rects: [
{
repoId: 'a',
bucketKey: 'ungrouped',
headerIndex: 0,
top: 100,
bottom: 128,
sectionBottom: nextHeaderTop
},
{ repoId: 'b', bucketKey: 'ungrouped', headerIndex: 1, top: nextHeaderTop, bottom: 248 }
]
})
expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: nextHeaderTop - INDICATOR_GAP })
})
describe('nearest-boundary choice across an interior gap', () => {
// Estimated section ends can drift from actual header geometry; test snapping on both sides of the gap.
const INDICATOR_GAP = 4
const prevSectionBottom = 200
const nextHeaderTop = 240
const sectionBottomSlotY = prevSectionBottom + INDICATOR_GAP
const nextHeaderSlotY = nextHeaderTop - INDICATOR_GAP
const midpointY = (sectionBottomSlotY + nextHeaderSlotY) / 2
const gapRects = [
{
repoId: 'a',
bucketKey: 'ungrouped',
headerIndex: 0,
top: 100,
bottom: 128,
sectionBottom: prevSectionBottom
},
{
repoId: 'b',
bucketKey: 'ungrouped',
headerIndex: 1,
top: nextHeaderTop,
bottom: 268,
sectionBottom: 340
}
] as const
it('snaps to the previous section bottom when the pointer is nearer to it', () => {
const preview = computeProjectHeaderDropPreview({
pointerY: sectionBottomSlotY + 1,
containerTop: 0,
scrollTop: 0,
sidebarRepoHeaderIds: ['a', 'b'],
rects: gapRects.map((rect) => ({ ...rect }))
})
expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: sectionBottomSlotY })
})
it('snaps to the next header top when the pointer is nearer to it', () => {
const preview = computeProjectHeaderDropPreview({
pointerY: nextHeaderSlotY - 1,
containerTop: 0,
scrollTop: 0,
sidebarRepoHeaderIds: ['a', 'b'],
rects: gapRects.map((rect) => ({ ...rect }))
})
expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: nextHeaderSlotY })
})
it('breaks the midpoint tie toward the next header boundary', () => {
const preview = computeProjectHeaderDropPreview({
pointerY: midpointY, // Equidistant boundaries prefer the next header.
containerTop: 0,
scrollTop: 0,
sidebarRepoHeaderIds: ['a', 'b'],
rects: gapRects.map((rect) => ({ ...rect }))
})
expect(preview).toEqual({ dropIndex: 1, dropIndicatorY: nextHeaderSlotY })
})
})
describe('content bound for the last section', () => {
const INDICATOR_GAP = 4
const estimatedSectionBottom = 380
const lastRects = [
{
repoId: 'c',
bucketKey: 'ungrouped',
headerIndex: 2,
top: 300,
bottom: 328,
sectionBottom: estimatedSectionBottom
}
] as const
const lastPreview = (pointerY: number, contentBottom: number) =>
computeProjectHeaderDropPreview({
pointerY,
containerTop: 0,
scrollTop: 0,
sidebarRepoHeaderIds: ['a', 'b', 'c'],
rects: lastRects.map((rect) => ({ ...rect })),
contentBottom
})
it('rejects a drop below the measured content when the estimate overshoots', () => {
expect(lastPreview(360, 340)).toBeNull()
})
it('rejects an edge-zone final drop below measured content when the estimate overshoots', () => {
// Cross the estimated end while remaining below measured content; the measured bound must win.
expect(lastPreview(389, 350)).toBeNull()
})
it('still snaps within the measured content when the estimate overshoots', () => {
expect(lastPreview(335, 340)).toEqual({
dropIndex: 3,
dropIndicatorY: estimatedSectionBottom + INDICATOR_GAP
})
})
it('snaps within the measured content when actual content undershoots the estimate', () => {
expect(lastPreview(360, 420)).toEqual({
dropIndex: 3,
dropIndicatorY: estimatedSectionBottom + INDICATOR_GAP
})
})
})
})
describe('applyAllRepoInsertAt', () => {
it('reorders repos using a full-list insertion index', () => {
expect(applyAllRepoInsertAt(['hidden', 'a', 'b', 'c'], 'c', 1)).toEqual([
'hidden',
'c',
'a',
'b'
])
})
it('moves duplicate host occurrences as one stable logical-project block', () => {
expect(applyAllRepoInsertAt(['b', 'same', 'c', 'same'], 'same', 0)).toEqual([
'same',
'same',
'b',
'c'
])
})
it('returns null for no-op reorders', () => {
expect(applyAllRepoInsertAt(['a', 'b', 'c'], 'b', 2)).toBeNull()
})
})
describe('getProjectGroupOrderForSidebarDrop', () => {
const repo = (id: string, projectGroupOrder?: number): Repo =>
({
id,
path: `/${id}`,
displayName: id,
badgeColor: '#000',
addedAt: 0,
projectGroupOrder
}) as Repo
it('uses a midpoint between sibling orders when there is room', () => {
expect(
getProjectGroupOrderForSidebarDrop({
siblings: [repo('a', 0), repo('b', 10)],
dropIndex: 1
})
).toBe(5)
})
it('uses manual repo rank as the fallback for missing sibling orders', () => {
expect(
getProjectGroupOrderForSidebarDrop({
siblings: [repo('a'), repo('c')],
dropIndex: 1,
repoOrderRankById: new Map([
['a', 0],
['b', 1],
['c', 2]
])
})
).toBe(1000)
})
it('keeps a deterministic finite anchor when sibling orders collide', () => {
expect(
getProjectGroupOrderForSidebarDrop({
siblings: [repo('a', 0), repo('b', 0)],
dropIndex: 1
})
).toBe(1)
})
it('assigns an order that sorts before siblings ranked by repo order', () => {
const order = getProjectGroupOrderForSidebarDrop({
siblings: [repo('a'), repo('b')],
dropIndex: 1,
repoOrderRankById: new Map([
['a', 0],
['b', 1],
['c', 2]
])
})
expect(order).toBeGreaterThan(0)
expect(order).toBeLessThan(2000)
})
})
@@ -1,408 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { useAppStore } from '@/store'
import { getWorktreeMapFromState } from '@/store/selectors'
import type { Worktree } from '../../../../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows'
import { DEFAULT_WORKSPACE_STATUSES } from '../../../../../../shared/workspace-status-defaults'
import {
makeFolderWorkspace,
makeWorktree
} from '../../../worktree-jump/worktree-jump-palette-test-fixtures'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspaces/folder-workspace-worktree'
import { useWorktreeStatusMutations } from './use-status-mutations'
import { useWorktreeDropCommitContext } from './use-drop-commit-context'
import { useWorktreeDragSession } from './use-session'
import type { WorktreeDropCommitContext } from './drop-commit-context'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), dismiss: vi.fn() } }))
const REPO_ID = 'repo-twin'
const TWIN_ID = `${REPO_ID}::/src/app`
const OTHER_ID = `${REPO_ID}::/src/other`
const SSH: ExecutionHostId = 'ssh:build-box'
const initialState = useAppStore.getInitialState()
const updateWorktreesMeta = vi.fn().mockResolvedValue(undefined)
function seedTwins(extra: Worktree[] = []): { local: Worktree; ssh: Worktree; dragged: Worktree } {
const local = makeWorktree(TWIN_ID, 'app', { repoId: REPO_ID, hostId: 'local' })
const ssh = makeWorktree(TWIN_ID, 'app', { repoId: REPO_ID, hostId: SSH })
const other = makeWorktree(OTHER_ID, 'other', { repoId: REPO_ID, hostId: 'local' })
useAppStore.setState({
sortBy: 'manual',
worktreesByRepo: { [REPO_ID]: [local, other, ssh, ...extra] },
updateWorktreesMeta
})
// Drag whichever copy the id-keyed map did not keep, so a by-id lookup picks the wrong one.
const kept = getWorktreeMapFromState(useAppStore.getState()).get(TWIN_ID)
return { local, ssh, dragged: kept?.hostId === SSH ? local : ssh }
}
function renderMutations(sortBy: 'manual' | 'smart' = 'manual') {
const worktrees = Object.values(useAppStore.getState().worktreesByRepo).flat()
return renderHook(() =>
useWorktreeStatusMutations({
manualOrderCatalog: buildWorktreeManualOrderCatalog({ worktrees, folderWorkspaces: [] }),
workspaceStatuses: DEFAULT_WORKSPACE_STATUSES,
sortBy
})
).result
}
function written(): {
worktreeId: string
updates?: Record<string, unknown>
executionHostId?: string
}[] {
return updateWorktreesMeta.mock.calls[0]?.[0] ?? []
}
function writtenHosts(
worktreeId = TWIN_ID,
field?: 'workspaceStatus' | 'manualOrder' | 'isPinned'
) {
return written()
.filter((u) => u.worktreeId === worktreeId && (!field || field in (u.updates ?? {})))
.map((u) => u.executionHostId)
.sort()
}
const copyOf = (row: Worktree) => [{ id: row.id, hostId: row.hostId }]
const keyOf = getWorktreeHostIdentity
describe('a sidebar drop on one card of a twin', () => {
beforeEach(() => {
useAppStore.setState(initialState, true)
updateWorktreesMeta.mockClear()
})
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
})
it('writes the status to the dragged copy, not the twin the id-keyed map kept', async () => {
const { dragged } = seedTwins()
const mutations = renderMutations()
await act(async () => {
mutations.current.moveWorktreesToStatusAtIndex({
worktreeIds: [TWIN_ID],
status: 'in-review',
dropIndex: 0,
groups: [{ key: 'in-review', worktreeIds: [] }],
draggedCopies: copyOf(dragged)
})
})
expect(writtenHosts(TWIN_ID, 'workspaceStatus')).toEqual([dragged.hostId])
})
// The sidebar ranks each card: same-id copies on two machines reorder independently.
it("writes a dragged twin's new rank to that copy only", async () => {
const { local, ssh, dragged } = seedTwins()
const other = useAppStore.getState().worktreesByRepo[REPO_ID]!.find((w) => w.id === OTHER_ID)!
const ranked = { [keyOf(local)]: 300, [keyOf(other)]: 200, [keyOf(ssh)]: 100 }
useAppStore.setState({
worktreesByRepo: {
[REPO_ID]: useAppStore
.getState()
.worktreesByRepo[REPO_ID]!.map((row) => ({ ...row, manualOrder: ranked[keyOf(row)] }))
}
})
const mutations = renderMutations()
await act(async () => {
mutations.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [keyOf(local), keyOf(other), keyOf(ssh)] }],
sourceGroupKey: 'all',
draggedKeys: [keyOf(dragged)],
dropIndex: dragged === ssh ? 1 : 3,
draggedCopies: copyOf(dragged)
})
})
expect(writtenHosts(TWIN_ID, 'manualOrder')).toEqual([dragged.hostId])
expect(writtenHosts(OTHER_ID)).toEqual([])
})
it('ranks every copy of a twin when a drop first materializes the order', async () => {
const { local, ssh } = seedTwins()
const mutations = renderMutations()
const other = useAppStore.getState().worktreesByRepo[REPO_ID]!.find((w) => w.id === OTHER_ID)!
await act(async () => {
// Drag the unique row above both twin cards; neither twin is dragged, but every card gets a rank.
mutations.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [keyOf(local), keyOf(ssh), keyOf(other)] }],
sourceGroupKey: 'all',
draggedKeys: [keyOf(other)],
dropIndex: 0,
draggedCopies: copyOf(other)
})
})
expect(writtenHosts(TWIN_ID, 'manualOrder')).toEqual(['local', SSH])
expect(writtenHosts(OTHER_ID)).toEqual(['local'])
})
it('still ranks an unranked folder workspace that shares the manual-order catalog', async () => {
const folder = makeFolderWorkspace({ id: 'folder-unranked' })
const a = makeWorktree(`${REPO_ID}::/a`, 'a', { repoId: REPO_ID, hostId: 'local' })
const b = makeWorktree(`${REPO_ID}::/b`, 'b', { repoId: REPO_ID, hostId: 'local' })
useAppStore.setState({
sortBy: 'manual',
worktreesByRepo: { [REPO_ID]: [a, b] },
folderWorkspaces: [folder],
updateWorktreesMeta
})
const catalog = buildWorktreeManualOrderCatalog({
worktrees: [a, b],
folderWorkspaces: [folder]
})
const { result } = renderHook(() =>
useWorktreeStatusMutations({
manualOrderCatalog: catalog,
workspaceStatuses: DEFAULT_WORKSPACE_STATUSES,
sortBy: 'manual'
})
)
await act(async () => {
result.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [keyOf(a), keyOf(b)] }],
sourceGroupKey: 'all',
draggedKeys: [keyOf(b)],
dropIndex: 0,
draggedCopies: copyOf(b)
})
})
expect(written().map((u) => u.worktreeId)).toContain(folderWorkspaceKey(folder.id))
})
it('lands a drop next to an unranked twin when the ranks are first materialized', async () => {
const b = makeWorktree(`${REPO_ID}::/b`, 'b', {
repoId: REPO_ID,
hostId: 'local',
sortOrder: 2
})
const a = makeWorktree(`${REPO_ID}::/a`, 'a', {
repoId: REPO_ID,
hostId: 'local',
sortOrder: 1
})
const aOnSsh = { ...a, hostId: SSH }
useAppStore.setState({
sortBy: 'manual',
worktreesByRepo: { [REPO_ID]: [b, a, aOnSsh] },
updateWorktreesMeta
})
const mutations = renderMutations()
await act(async () => {
mutations.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [keyOf(b), keyOf(a)] }],
sourceGroupKey: 'all',
draggedKeys: [keyOf(b)],
dropIndex: 2,
draggedCopies: copyOf(b)
})
})
const applied = [b, a, aOnSsh].map((row) => {
const write = written().find(
(u) => u.worktreeId === row.id && u.executionHostId === row.hostId
)
return write ? { ...row, ...write.updates } : row
})
expect(
buildWorktreeManualOrderCatalog({ worktrees: applied, folderWorkspaces: [] }).orderedKeys
).toEqual([keyOf(a), keyOf(b), keyOf(aOnSsh)])
})
it('ranks a selected folder workspace dragged together with a git card', async () => {
const folder = makeFolderWorkspace({ id: 'folder-selected', executionHostId: 'local' })
const a = makeWorktree(`${REPO_ID}::/a`, 'a', { repoId: REPO_ID, hostId: 'local' })
const b = makeWorktree(`${REPO_ID}::/b`, 'b', { repoId: REPO_ID, hostId: 'local' })
useAppStore.setState({
sortBy: 'manual',
worktreesByRepo: { [REPO_ID]: [a, b] },
folderWorkspaces: [folder],
updateWorktreesMeta
})
const folderId = folderWorkspaceKey(folder.id)
const folderKey = keyOf(folderWorkspaceToWorktree(folder))
const mutations = renderMutations()
await act(async () => {
mutations.current.reorderWorktrees({
groups: [{ key: 'all', worktreeIds: [keyOf(a), keyOf(b), folderKey] }],
sourceGroupKey: 'all',
draggedKeys: [keyOf(b), folderKey],
dropIndex: 0,
draggedCopies: [...copyOf(b), { id: folderId, hostId: 'local' }]
})
})
expect(written().map((u) => u.worktreeId)).toContain(folderId)
})
it('refuses a dragged copy that left its host mid-drag rather than writing its twin', async () => {
const { dragged } = seedTwins()
useAppStore.setState({
worktreesByRepo: {
[REPO_ID]: useAppStore.getState().worktreesByRepo[REPO_ID]!.filter((w) => w !== dragged)
}
})
const mutations = renderMutations()
await act(async () => {
mutations.current.moveWorktreesToStatus([TWIN_ID], 'in-review', copyOf(dragged))
})
expect(updateWorktreesMeta).not.toHaveBeenCalled()
})
it("carries a dragged twin's own lineage children, with their host, not the other copy's", () => {
const { dragged } = seedTwins()
const twinOf: ExecutionHostId = dragged.hostId === SSH ? 'local' : SSH
const childOnDraggedHost = makeWorktree(`${REPO_ID}::/src/child-a`, 'child-a', {
repoId: REPO_ID,
hostId: dragged.hostId
})
const childOfTwin = makeWorktree(`${REPO_ID}::/src/child-b`, 'child-b', {
repoId: REPO_ID,
hostId: twinOf
})
const twin = { ...dragged, hostId: twinOf }
const item = (worktree: Worktree, depth: number): HostSectionRow => ({
type: 'item',
rowKey: `${worktree.hostId}|${worktree.id}`,
sectionKey: 'all',
worktree,
repo: undefined,
depth,
groupDepth: 0,
isLastLineageChild: depth > 0,
lineageChildCount: depth === 0 ? 1 : 0
})
const rows = [
item(dragged, 0),
item(childOnDraggedHost, 1),
item(twin, 0),
item(childOfTwin, 1)
]
const { result } = renderHook(() =>
useWorktreeDragSession({ rows, scrollRef: { current: null } })
)
expect(result.current.getReorderDraggedCopies(copyOf(dragged))).toEqual([
{ id: dragged.id, hostId: dragged.hostId },
{ id: childOnDraggedHost.id, hostId: dragged.hostId }
])
})
it('previews the board insertion line from the dragged copy, as the commit reads it', () => {
seedTwins()
const kept = getWorktreeMapFromState(useAppStore.getState()).get(TWIN_ID)
// The kept twin already sits in the target lane; the dragged copy does not.
useAppStore.setState({
sortBy: 'smart',
worktreesByRepo: {
[REPO_ID]: useAppStore
.getState()
.worktreesByRepo[REPO_ID]!.map((row) =>
row === kept
? { ...row, workspaceStatus: 'in-review' }
: { ...row, workspaceStatus: 'todo' }
)
}
})
const dragged = useAppStore
.getState()
.worktreesByRepo[REPO_ID]!.find((row) => row.id === TWIN_ID && row.hostId !== kept?.hostId)!
const mutations = renderMutations('smart')
expect(
mutations.current.shouldShowWorkspaceBoardDropIndicator(
[TWIN_ID],
'in-review',
copyOf(dragged)
)
).toBe(false)
})
it('pins the dragged copy of a twin dropped on Pinned, not the copy the id-keyed map kept', async () => {
const { dragged } = seedTwins()
const mutations = renderMutations()
await act(async () => {
mutations.current.pinWorktrees([TWIN_ID], copyOf(dragged))
})
expect(writtenHosts(TWIN_ID, 'isPinned')).toEqual([dragged.hostId])
})
it('refuses a host-less pin of an id two hosts publish', async () => {
seedTwins()
const mutations = renderMutations()
await act(async () => {
mutations.current.pinWorktree(TWIN_ID)
useAppStore.getState().setWorktreesPinnedAndReveal([{ id: TWIN_ID }], true)
})
expect(updateWorktreesMeta).not.toHaveBeenCalled()
})
it('hands the drop the copies the active drag picked up', () => {
const onPinWorktrees = vi.fn()
const onMoveWorktreesToStatusAtIndex = vi.fn()
const onReorderWorktrees = vi.fn()
const onMoveWorktreesToStatus = vi.fn()
const draggedCopies = [{ id: TWIN_ID, hostId: SSH }] as const
const { result } = renderHook(() =>
useWorktreeDropCommitContext({
scrollRef: { current: null },
workspaceStatuses: [],
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the context reads only the drag-session ref and pass-through fields here.
session: { worktreeDragSessionRef: { current: { draggedCopies } } } as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, only the pointer-drag ref is read.
lineageDrop: {} as never,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above, only the pointer-drag ref is read.
runtime: { worktreePointerDragRef: { current: null } } as never,
onMoveWorktreesToStatus,
onMoveWorktreesToStatusAtIndex,
onReorderWorktrees,
onPinWorktrees
})
)
const ctx: WorktreeDropCommitContext = result.current
ctx.onMoveWorktreesToStatus([TWIN_ID], 'done')
ctx.onMoveWorktreesToStatusAtIndex({
worktreeIds: [TWIN_ID],
status: 'done',
dropIndex: 0,
groups: []
})
ctx.onReorderWorktrees({
groups: [],
sourceGroupKey: 'all',
draggedKeys: [keyOf({ id: TWIN_ID, hostId: SSH })],
dropIndex: 0
})
expect(onMoveWorktreesToStatus).toHaveBeenCalledWith([TWIN_ID], 'done', draggedCopies)
expect(onMoveWorktreesToStatusAtIndex.mock.calls[0][0].draggedCopies).toBe(draggedCopies)
expect(onReorderWorktrees.mock.calls[0][0].draggedCopies).toBe(draggedCopies)
ctx.onPinWorktrees([TWIN_ID])
expect(onPinWorktrees).toHaveBeenCalledWith([TWIN_ID], draggedCopies)
})
})
@@ -1,242 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { planSidebarRows } from '../../../../../../shared/sidebar-list/sidebar-row-plan'
import { createHostScopedRepoOf } from '../../../../../../shared/sidebar-list/sidebar-feed-index'
import {
NO_LIVE_SMART_SORT_SOURCES,
rankWorktreesForSort
} from '../../../../../../shared/sidebar-list/smart-sort'
import { cloneDefaultWorkspaceStatuses } from '../../../../../../shared/workspace-statuses'
import { computeVisibleWorktrees } from '../listing/compute-visible-worktrees'
import { repo, worktree } from '../grouping/worktree-list-groups-test-fixtures'
import {
buildManualOrderUpdatesForGroupDrop,
buildManualOrderUpdatesForVisibleGroups
} from './worktree-manual-order'
import { buildWorktreeManualOrderCatalog } from './worktree-manual-order-catalog'
import { collectDropWrites, dropWriteRows } from './dragged-worktree-copies'
import { getWorktreeDragGroups } from './groups'
// Per-workspace VMs cloned from one base share the repo id and path, so every primary has the same id.
const REPO_ID = 'repo-teal'
const TWIN_ID = `${REPO_ID}::/home/dev/teal`
const HOSTS = ['ssh:vm-a', 'ssh:vm-b', 'ssh:vm-c'] as const satisfies readonly ExecutionHostId[]
const repos: Repo[] = HOSTS.map((hostId) => ({
...repo,
id: REPO_ID,
path: '/home/dev/teal',
displayName: 'teal',
connectionId: hostId.slice('ssh:'.length)
}))
const repoMap = new Map([[REPO_ID, repos[0]!]])
function primary(hostId: ExecutionHostId, manualOrder: number): Worktree {
return {
...worktree,
id: TWIN_ID,
repoId: REPO_ID,
path: '/home/dev/teal',
hostId,
isMainWorktree: true,
displayName: hostId,
manualOrder
}
}
/** The window's Manual path: rank, filter, plan rows, then read the cards' hosts in order. */
function renderedHosts(worktrees: Worktree[]): string[] {
const sortedKeys = rankWorktreesForSort({
worktrees: [...worktrees],
sortBy: 'manual',
latched: false,
repoMap,
now: 0,
sources: NO_LIVE_SMART_SORT_SOURCES
}).sorted.map(getWorktreeHostIdentity)
const visible = computeVisibleWorktrees({ [REPO_ID]: worktrees }, sortedKeys, {
filterRepoIds: [],
showSleepingWorkspaces: true,
tabsByWorktree: {},
ptyIdsByTabId: {},
worktreeIdsWithLiveAgent: new Set(),
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
hideWorkspacesFromOtherDevices: false,
pairedDeviceIdsByEnvironment: new Map(),
repoMap,
workspaceHostScope: 'all',
defaultHostId: 'local',
worktreeLineageById: {},
sortedByHostIdentity: true
})
return planRows(visible).flatMap((row) => (row.type === 'item' ? [row.worktree.hostId!] : []))
}
function planRows(worktrees: Worktree[]) {
return planSidebarRows({
groupBy: 'repo',
sortBy: 'manual',
worktrees,
repoMap,
repoOfWorktree: createHostScopedRepoOf(repos, repoMap),
prGroupOf: () => 'in-progress',
workspaceStatuses: cloneDefaultWorkspaceStatuses(),
projectOrderBy: 'manual',
lineageById: {},
worktreeMap: new Map(worktrees.map((row) => [row.id, row])),
nestLineage: false,
projectGroups: [],
placeholderRepoIds: new Set(),
importedWorktreesByRepo: new Map(),
newExternalWorktreesInboxByRepo: new Map(),
pendingCreations: [],
folderWorkspaces: [],
defaultHostId: 'local',
pinnedDisplayPolicy: 'single-location',
filterRepoIds: []
}).render(new Set())
}
/** Drop one card at a slot the way the sidebar commits it, and apply the writes it would send. */
function dropCard(worktrees: Worktree[], hostId: ExecutionHostId, dropIndex: number) {
const groups = getWorktreeDragGroups(planRows(worktrees))
const catalog = buildWorktreeManualOrderCatalog({ worktrees, folderWorkspaces: [] })
const dragged = worktrees.find((row) => row.hostId === hostId)!
const result = buildManualOrderUpdatesForVisibleGroups({
groups,
sourceGroupKey: groups[0]!.key,
draggedIds: [getWorktreeHostIdentity(dragged)],
dropIndex,
now: 1_000_000,
rankByWorktreeId: catalog.rankByKey,
allWorktreeIds: catalog.orderedKeys
})
const writes = collectDropWrites({
rowsFor: dropWriteRows(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dropWriteRows reads only these two slices.
{ worktreesByRepo: { [REPO_ID]: worktrees }, folderWorkspaces: [] } as never,
[{ id: dragged.id, hostId: dragged.hostId }]
),
statusIds: [],
status: null,
workspaceStatuses: cloneDefaultWorkspaceStatuses(),
orderUpdatesByKey: result.updates
})
const applied = worktrees.map((row) => {
const write = writes.find((entry) => entry.executionHostId === row.hostId)
return write ? { ...row, ...write.updates } : row
})
return { groups, writes, applied }
}
describe('reordering same-id primaries on separate machines', () => {
const twins = [primary('ssh:vm-a', 300), primary('ssh:vm-b', 200), primary('ssh:vm-c', 100)]
it('renders each copy at its own rank', () => {
expect(renderedHosts(twins)).toEqual(['ssh:vm-a', 'ssh:vm-b', 'ssh:vm-c'])
expect(renderedHosts([twins[2]!, twins[0]!, twins[1]!])).toEqual([
'ssh:vm-a',
'ssh:vm-b',
'ssh:vm-c'
])
})
it('gives every copy its own drag slot', () => {
const { groups } = dropCard(twins, 'ssh:vm-c', 0)
expect(groups[0]!.worktreeIds).toEqual(HOSTS.map((hostId) => `${hostId}|${TWIN_ID}`))
})
it('moves the dropped copy to the top and writes only that copy', () => {
const { writes, applied } = dropCard(twins, 'ssh:vm-c', 0)
expect(writes.map((entry) => entry.executionHostId)).toEqual(['ssh:vm-c'])
expect(renderedHosts(applied)).toEqual(['ssh:vm-c', 'ssh:vm-a', 'ssh:vm-b'])
})
it('moves a copy down past its twins', () => {
const { applied } = dropCard(twins, 'ssh:vm-a', 3)
expect(renderedHosts(applied)).toEqual(['ssh:vm-b', 'ssh:vm-c', 'ssh:vm-a'])
})
const feature = {
...primary('ssh:vm-c', 50),
id: `${REPO_ID}::/home/dev/feature`,
path: '/home/dev/feature',
isMainWorktree: false
}
it('drops a ranked primary past a linked worktree, writing only that primary', () => {
const { writes, applied } = dropCard([...twins.slice(0, 2), feature], 'ssh:vm-b', 3)
expect(writes.map((entry) => entry.executionHostId)).toEqual(['ssh:vm-b'])
expect(renderedHosts(applied)).toEqual(['ssh:vm-a', 'ssh:vm-c', 'ssh:vm-b'])
})
it('drops a primary past a linked worktree when unranked rows materialize ranks', () => {
const unranked = [...twins.slice(0, 2), feature].map(
({ manualOrder: _manualOrder, ...row }) => row
)
const { writes, applied } = dropCard(unranked, 'ssh:vm-b', 3)
expect(writes.map((entry) => entry.executionHostId).sort()).toEqual([...HOSTS])
expect(renderedHosts(applied)).toEqual(['ssh:vm-a', 'ssh:vm-c', 'ssh:vm-b'])
})
it('materializes a rank for every copy when none has one yet', () => {
const unranked = twins.map(({ manualOrder: _manualOrder, ...row }, index) => ({
...row,
sortOrder: 30 - index * 10
}))
const { writes, applied } = dropCard(unranked, 'ssh:vm-b', 0)
expect(writes.map((entry) => entry.executionHostId).sort()).toEqual([...HOSTS])
expect(renderedHosts(applied)).toEqual(['ssh:vm-b', 'ssh:vm-a', 'ssh:vm-c'])
})
it('places a board drop next to the lower copy, writing only the dragged card', () => {
// Lane a, vm-a copy, b, c, vm-b copy, d: the copies sit apart after a sidebar reorder.
const card = (name: string, manualOrder: number): Worktree => ({
...worktree,
id: `${REPO_ID}::/w/${name}`,
repoId: REPO_ID,
path: `/w/${name}`,
hostId: 'ssh:vm-a',
displayName: name,
manualOrder
})
const lane = [
card('a', 0),
primary('ssh:vm-a', -1_000),
card('b', -2_000),
card('c', -3_000),
primary('ssh:vm-b', -3_500),
card('d', -4_000)
]
const catalog = buildWorktreeManualOrderCatalog({ worktrees: lane, folderWorkspaces: [] })
const drop = (dropIndex: number) =>
buildManualOrderUpdatesForGroupDrop({
groups: [{ key: 'todo', worktreeIds: lane.map(getWorktreeHostIdentity) }],
targetGroupKey: 'todo',
draggedIds: [getWorktreeHostIdentity(lane[0]!)],
dropIndex,
now: 2_000_000,
rankByWorktreeId: catalog.rankByKey,
allWorktreeIds: catalog.orderedKeys
})
// Just below the vm-b copy: between it and d.
expect([...drop(5).updates]).toEqual([
[getWorktreeHostIdentity(lane[0]!), { manualOrder: -3_750 }]
])
// Between c and the vm-b copy: still sparse, no full reindex.
expect([...drop(4).updates]).toEqual([
[getWorktreeHostIdentity(lane[0]!), { manualOrder: -3_250 }]
])
})
})
@@ -1,95 +1,10 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { getCyclicProjectedWorktreeLineageIds } from '../../../../../../shared/sidebar-list/worktree-lineage-projection'
import {
getReorderedWorktreeIdsToUnnest,
getWorktreeLineageDropTargetId,
isWorktreeLineageDropZoneHit
} from './worktree-lineage-drag-drop'
describe('isWorktreeLineageDropZoneHit', () => {
it('keeps the top and bottom of a card available for reorder drops', () => {
const rect = { top: 100, bottom: 200 } as DOMRect
expect(isWorktreeLineageDropZoneHit({ pointerY: 107, rect })).toBe(false)
expect(isWorktreeLineageDropZoneHit({ pointerY: 150, rect })).toBe(true)
expect(isWorktreeLineageDropZoneHit({ pointerY: 193, rect })).toBe(false)
})
it('makes most of tall cards available for nesting', () => {
const rect = { top: 0, bottom: 180 } as DOMRect
expect(isWorktreeLineageDropZoneHit({ pointerY: 8, rect })).toBe(true)
expect(isWorktreeLineageDropZoneHit({ pointerY: 90, rect })).toBe(true)
expect(isWorktreeLineageDropZoneHit({ pointerY: 172, rect })).toBe(true)
})
it('scales down reorder gutters for compact cards', () => {
const rect = { top: 100, bottom: 120 }
expect(isWorktreeLineageDropZoneHit({ pointerY: 103, rect })).toBe(false)
expect(isWorktreeLineageDropZoneHit({ pointerY: 104, rect })).toBe(true)
expect(isWorktreeLineageDropZoneHit({ pointerY: 116, rect })).toBe(true)
expect(isWorktreeLineageDropZoneHit({ pointerY: 117, rect })).toBe(false)
})
it.each([
{ top: 100, bottom: 100 },
{ top: 100, bottom: 90 }
])('rejects empty and inverted rectangles', (rect) => {
expect(isWorktreeLineageDropZoneHit({ pointerY: 100, rect })).toBe(false)
})
})
import { getWorktreeLineageDropTargetId } from './worktree-lineage-drag-drop'
describe('getWorktreeLineageDropTargetId', () => {
it('returns the row id only when the pointer is away from the reorder gutters', () => {
const { container, target } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 })
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 107 })).toBeNull()
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent')
})
it('ignores content targets outside the sidebar container', () => {
const { container, target } = makeTarget({
worktreeId: 'parent',
top: 100,
bottom: 200,
contained: false
})
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull()
})
it.each(['status', 'agent'] as const)(
'keeps the %s region in the lineage nesting hit zone',
(targetRole) => {
const { container, target } = makeTarget({
worktreeId: 'parent',
top: 100,
bottom: 200,
targetRole
})
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent')
}
)
it('accepts horizontal card padding outside the content element', () => {
const { container } = makeTarget({ worktreeId: 'parent', top: 100, bottom: 200 })
const row = container.firstElementChild!
const padding = document.createElement('div')
row.append(padding)
expect(getWorktreeLineageDropTargetId({ container, target: padding, pointerY: 120 })).toBe(
'parent'
)
expect(getWorktreeLineageDropTargetId({ container, target: row, pointerY: 180 })).toBe('parent')
expect(getWorktreeLineageDropTargetId({ container, target: padding, pointerY: 201 })).toBeNull()
})
it.each([true, false])(
'keeps descendants out of the ancestor hit zone (inline content: %s)',
(insideParentContent) => {
@@ -119,138 +34,8 @@ describe('getWorktreeLineageDropTargetId', () => {
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 120 })).toBe('parent')
}
)
it('does not use a descendant content element for a row without its own content', () => {
const { container, target } = makeTarget({ worktreeId: 'child', top: 100, bottom: 200 })
const parentRow = document.createElement('div')
parentRow.setAttribute('data-worktree-drag-id', 'parent')
parentRow.append(container.firstElementChild!)
container.append(parentRow)
expect(
getWorktreeLineageDropTargetId({ container, target: parentRow, pointerY: 150 })
).toBeNull()
expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('child')
})
})
describe('getReorderedWorktreeIdsToUnnest', () => {
it('clears parents only for directly dragged nested cards', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const root = makeWorktree('root')
const grandchild = makeWorktree('grandchild')
const lineageById = {
[child.id]: makeLineage(child, parent),
[grandchild.id]: makeLineage(grandchild, child)
}
const worktreeMap = new Map([parent, child, root, grandchild].map((item) => [item.id, item]))
expect(
getReorderedWorktreeIdsToUnnest({
draggedIds: ['child', 'child', 'root', 'grandchild'],
sourceGroupIds: ['child', 'root', 'grandchild'],
lineageById,
worktreeMap,
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
})
).toEqual(['child', 'grandchild'])
})
it('does not clear selected nested cards outside the reordered source group', () => {
const parent = makeWorktree('parent')
const sourceChild = makeWorktree('source-child')
const otherChild = makeWorktree('other-child')
const lineageById = {
[sourceChild.id]: makeLineage(sourceChild, parent),
[otherChild.id]: makeLineage(otherChild, parent)
}
const worktreeMap = new Map([parent, sourceChild, otherChild].map((item) => [item.id, item]))
expect(
getReorderedWorktreeIdsToUnnest({
draggedIds: ['source-child', 'other-child'],
sourceGroupIds: ['source-child'],
lineageById,
worktreeMap,
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
})
).toEqual(['source-child'])
})
it('clears an exact inline-only legacy parent', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item]))
expect(
getReorderedWorktreeIdsToUnnest({
draggedIds: [child.id],
sourceGroupIds: [child.id],
lineageById: {},
worktreeMap,
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds({}, worktreeMap)
})
).toEqual([child.id])
})
it('does not fall back to inline lineage when the side-map has a stale child entry', () => {
const parent = makeWorktree('parent')
const child = makeWorktree('child')
const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree
const lineageById = {
[child.id]: { ...makeLineage(child, parent), parentWorktreeInstanceId: 'stale-parent' }
}
const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item]))
expect(
getReorderedWorktreeIdsToUnnest({
draggedIds: [child.id],
sourceGroupIds: [child.id],
lineageById,
worktreeMap,
cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
})
).toEqual([])
})
})
function makeWorktree(id: string): Worktree {
return {
id,
instanceId: `${id}-instance`,
repoId: 'repo-1',
path: `/worktrees/${id}`,
head: 'abc123',
branch: id,
isBare: false,
isMainWorktree: false,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1
}
}
function makeLineage(child: Worktree, parent: Worktree): WorktreeLineage {
return {
worktreeId: child.id,
worktreeInstanceId: child.instanceId ?? '',
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId ?? '',
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 1
}
}
function makeTarget(args: {
worktreeId: string
top: number
@@ -1,11 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildWorktreeDragPreviewOffsets } from './worktree-drag-preview-offsets'
import {
buildManualOrderUpdatesForGroupDrop,
buildManualOrderUpdatesForVisibleGroups,
expandDraggedWorktreeIdsForVisibleLineage,
moveWorktreeIdsWithinGroup,
shouldWriteManualOrderForGroupDrop
moveWorktreeIdsWithinGroup
} from './worktree-manual-order'
import { buildSparseManualOrderUpdates } from './worktree-manual-order-ranks'
@@ -29,35 +26,6 @@ describe('buildSparseManualOrderUpdates durable migration', () => {
])
})
it('keeps sparse updates after every known row has a durable rank', () => {
const result = buildSparseManualOrderUpdates({
orderedIds: ['a', 'c', 'b'],
movedIds: ['b'],
allWorktreeIds: ['a', 'b', 'hidden', 'c'],
rankByWorktreeId: new Map([
['a', 4000],
['b', 3000],
['hidden', 2000],
['c', 1000]
]),
now: 10_000
})
expect([...result]).toEqual([['b', { manualOrder: 0 }]])
})
it('never drops known rows when a stale visible sequence contains unknowns or duplicates', () => {
const result = buildSparseManualOrderUpdates({
orderedIds: ['unknown', 'b', 'b', 'a'],
movedIds: ['b'],
allWorktreeIds: ['a', 'b', 'hidden', 'c', 'c'],
rankByWorktreeId: new Map(),
now: 10_000
})
expect([...result.keys()]).toEqual(['b', 'a', 'hidden', 'c', 'unknown'])
})
it('preserves hidden rows when dense ranks require a full reindex', () => {
const result = buildSparseManualOrderUpdates({
orderedIds: ['a', 'c', 'b'],
@@ -74,109 +42,9 @@ describe('buildSparseManualOrderUpdates durable migration', () => {
expect([...result.keys()]).toEqual(['a', 'hidden', 'c', 'b'])
})
it('preserves hidden rows when a stale neighbor requires a full reindex', () => {
const result = buildSparseManualOrderUpdates({
orderedIds: ['stale', 'b', 'a'],
movedIds: ['b'],
allWorktreeIds: ['a', 'hidden', 'b'],
rankByWorktreeId: new Map([
['a', 3000],
['hidden', 2000],
['b', 1000]
]),
now: 10_000
})
expect([...result.keys()]).toEqual(['b', 'hidden', 'a', 'stale'])
})
})
describe('expandDraggedWorktreeIdsForVisibleLineage', () => {
it('expands an expanded lineage parent to its visible descendants for reordering', () => {
expect(
expandDraggedWorktreeIdsForVisibleLineage(
[
{ worktreeId: 'parent', depth: 0 },
{ worktreeId: 'child', depth: 1 },
{ worktreeId: 'grandchild', depth: 2 },
{ worktreeId: 'sibling', depth: 0 }
],
['parent']
)
).toEqual(['parent', 'child', 'grandchild'])
})
it('keeps unrelated selected rows in visual order', () => {
expect(
expandDraggedWorktreeIdsForVisibleLineage(
[
{ worktreeId: 'a', depth: 0 },
{ worktreeId: 'parent', depth: 0 },
{ worktreeId: 'child', depth: 1 },
{ worktreeId: 'z', depth: 0 }
],
['z', 'parent']
)
).toEqual(['parent', 'child', 'z'])
})
it('keeps ancestor coverage when a nested descendant is selected too', () => {
expect(
expandDraggedWorktreeIdsForVisibleLineage(
[
{ worktreeId: 'parent', depth: 0 },
{ worktreeId: 'child', depth: 2 },
{ worktreeId: 'uncleish', depth: 1 },
{ worktreeId: 'sibling', depth: 0 }
],
['parent', 'child']
)
).toEqual(['parent', 'child', 'uncleish'])
})
it('restarts coverage for a later selected sibling after the previous run ends', () => {
expect(
expandDraggedWorktreeIdsForVisibleLineage(
[
{ worktreeId: 'first', depth: 0 },
{ worktreeId: 'first-child', depth: 1 },
{ worktreeId: 'second', depth: 0 },
{ worktreeId: 'second-child', depth: 1 }
],
['first', 'second']
)
).toEqual(['first', 'first-child', 'second', 'second-child'])
})
it('emits each absent selected id once', () => {
expect(
expandDraggedWorktreeIdsForVisibleLineage(
[{ worktreeId: 'row', depth: 0 }],
['gone', 'row', 'gone']
)
).toEqual(['row', 'gone'])
})
})
describe('moveWorktreeIdsWithinGroup', () => {
it('moves a worktree down using the original drop index', () => {
expect(moveWorktreeIdsWithinGroup(['a', 'b', 'c', 'd'], ['b'], 4)).toEqual(['a', 'c', 'd', 'b'])
})
it('moves a worktree up', () => {
expect(moveWorktreeIdsWithinGroup(['a', 'b', 'c'], ['c'], 0)).toEqual(['c', 'a', 'b'])
})
it('preserves selected order for multi-drag batches', () => {
expect(moveWorktreeIdsWithinGroup(['a', 'b', 'c', 'd'], ['c', 'b'], 4)).toEqual([
'a',
'd',
'b',
'c'
])
})
it('moves a very large selected batch without overflowing argument limits', () => {
const ids = Array.from({ length: 130_000 }, (_, index) => `wt-${index}`)
@@ -188,247 +56,7 @@ describe('moveWorktreeIdsWithinGroup', () => {
})
})
describe('buildWorktreeDragPreviewOffsets', () => {
it('slides intervening rows up while dragging a row down', () => {
const { offsets } = buildWorktreeDragPreviewOffsets({
groupIds: ['a', 'b', 'c', 'd'],
draggedIds: ['b'],
dropIndex: 4,
rects: [
{ worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 },
{ worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 },
{ worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 },
{ worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 }
]
})
expect(Array.from(offsets)).toEqual([
['c', -56],
['d', -56]
])
})
it('keeps downward offsets stable after virtualization unmounts the leading rows', () => {
const { offsets, placeholderTop } = buildWorktreeDragPreviewOffsets({
groupIds: ['a', 'b', 'c', 'd', 'e', 'f'],
draggedIds: ['a'],
draggingWorktreeId: 'a',
draggedPreviewHeight: 50,
dropIndex: 6,
rects: [
{ worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 },
{ worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 },
{ worktreeId: 'f', groupIndex: 5, top: 280, bottom: 330 }
]
})
expect(Array.from(offsets)).toEqual([
['d', -56],
['e', -56],
['f', -56]
])
expect(placeholderTop).toBe(280)
})
it('slides intervening rows down while dragging a row up', () => {
const { offsets } = buildWorktreeDragPreviewOffsets({
groupIds: ['a', 'b', 'c'],
draggedIds: ['c'],
dropIndex: 0,
rects: [
{ worktreeId: 'a', groupIndex: 0, top: 0, bottom: 40 },
{ worktreeId: 'b', groupIndex: 1, top: 46, bottom: 86 },
{ worktreeId: 'c', groupIndex: 2, top: 92, bottom: 132 }
]
})
expect(Array.from(offsets)).toEqual([
['a', 46],
['b', 46]
])
})
it('returns no preview offsets for a no-op hover', () => {
const { offsets } = buildWorktreeDragPreviewOffsets({
groupIds: ['a', 'b'],
draggedIds: ['a'],
dropIndex: 1,
rects: [
{ worktreeId: 'a', groupIndex: 0, top: 0, bottom: 40 },
{ worktreeId: 'b', groupIndex: 1, top: 46, bottom: 86 }
]
})
expect(offsets.size).toBe(0)
})
it('uses the dragged unit height when previewing variable-height rows', () => {
const { offsets } = buildWorktreeDragPreviewOffsets({
groupIds: ['parent', 'sibling'],
draggedIds: ['parent'],
dropIndex: 2,
rects: [
{ worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 300 },
{ worktreeId: 'sibling', groupIndex: 1, top: 308, bottom: 408 }
]
})
expect(Array.from(offsets)).toEqual([['sibling', -308]])
})
it('uses the dragged unit height when previewing a short row above a tall row', () => {
const { offsets } = buildWorktreeDragPreviewOffsets({
groupIds: ['parent', 'sibling'],
draggedIds: ['sibling'],
dropIndex: 0,
rects: [
{ worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 300 },
{ worktreeId: 'sibling', groupIndex: 1, top: 308, bottom: 408 }
]
})
expect(Array.from(offsets)).toEqual([['parent', 108]])
})
it('reserves one card-height slot while previewing a multi-select batch', () => {
const { offsets } = buildWorktreeDragPreviewOffsets({
groupIds: ['a', 'b', 'c', 'd', 'e'],
draggedIds: ['b', 'c', 'd'],
draggingWorktreeId: 'b',
dropIndex: 5,
rects: [
{ worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 },
{ worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 },
{ worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 },
{ worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 },
{ worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }
]
})
expect(Array.from(offsets)).toEqual([
['c', -56],
['d', -56],
['e', -56]
])
})
it('uses the grabbed selected card as the one preview placeholder', () => {
const { offsets } = buildWorktreeDragPreviewOffsets({
groupIds: ['a', 'b', 'c', 'd', 'e'],
draggedIds: ['b', 'c', 'd'],
draggingWorktreeId: 'd',
dropIndex: 5,
rects: [
{ worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 },
{ worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 },
{ worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 },
{ worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 },
{ worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }
]
})
expect(Array.from(offsets)).toEqual([['e', -56]])
})
it('returns no preview offsets for a no-op multi-select hover', () => {
const { offsets } = buildWorktreeDragPreviewOffsets({
groupIds: ['a', 'b', 'c', 'd', 'e'],
draggedIds: ['b', 'c', 'd'],
draggingWorktreeId: 'b',
dropIndex: 4,
rects: [
{ worktreeId: 'a', groupIndex: 0, top: 0, bottom: 50 },
{ worktreeId: 'b', groupIndex: 1, top: 56, bottom: 106 },
{ worktreeId: 'c', groupIndex: 2, top: 112, bottom: 162 },
{ worktreeId: 'd', groupIndex: 3, top: 168, bottom: 218 },
{ worktreeId: 'e', groupIndex: 4, top: 224, bottom: 274 }
]
})
expect(offsets.size).toBe(0)
})
})
describe('buildManualOrderUpdatesForVisibleGroups', () => {
it('updates every visible workspace order while only moving the source group', () => {
const result = buildManualOrderUpdatesForVisibleGroups({
groups: [
{ key: 'workspace-status:todo', worktreeIds: ['todo-a', 'todo-b'] },
{ key: 'workspace-status:done', worktreeIds: ['done-a', 'done-b'] }
],
sourceGroupKey: 'workspace-status:todo',
draggedIds: ['todo-b'],
dropIndex: 0,
allWorktreeIds: ['todo-a', 'todo-b', 'done-a', 'done-b'],
now: 10_000
})
expect(result.changed).toBe(true)
expect(result.orderedIds).toEqual(['todo-b', 'todo-a', 'done-a', 'done-b'])
expect(Array.from(result.updates)).toEqual([
['todo-b', { manualOrder: 10_000 }],
['todo-a', { manualOrder: 9000 }],
['done-a', { manualOrder: 8000 }],
['done-b', { manualOrder: 7000 }]
])
})
it('returns no updates for a no-op drop', () => {
const result = buildManualOrderUpdatesForVisibleGroups({
groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }],
sourceGroupKey: 'repo:one',
draggedIds: ['a'],
dropIndex: 1,
allWorktreeIds: ['a', 'b'],
now: 10_000
})
expect(result.changed).toBe(false)
expect(result.updates.size).toBe(0)
})
it('only updates moved rows when current ranks leave room', () => {
const result = buildManualOrderUpdatesForVisibleGroups({
groups: [{ key: 'repo:one', worktreeIds: ['a', 'b', 'c', 'd'] }],
sourceGroupKey: 'repo:one',
draggedIds: ['b'],
dropIndex: 4,
allWorktreeIds: ['a', 'b', 'c', 'd'],
now: 10_000,
rankByWorktreeId: new Map([
['a', 4000],
['b', 3000],
['c', 2000],
['d', 1000]
])
})
expect(result.orderedIds).toEqual(['a', 'c', 'd', 'b'])
expect(Array.from(result.updates)).toEqual([['b', { manualOrder: 0 }]])
})
it('moves an expanded lineage cluster as one ranked unit', () => {
const result = buildManualOrderUpdatesForVisibleGroups({
groups: [{ key: 'all', worktreeIds: ['parent', 'child', 'other'] }],
sourceGroupKey: 'all',
draggedIds: ['parent', 'child'],
dropIndex: 3,
allWorktreeIds: ['parent', 'child', 'other'],
now: 10_000,
rankByWorktreeId: new Map([
['parent', 3000],
['child', 2000],
['other', 1000]
])
})
expect(result.orderedIds).toEqual(['other', 'parent', 'child'])
expect(Array.from(result.updates)).toEqual([
['parent', { manualOrder: 0 }],
['child', { manualOrder: -1000 }]
])
})
it('reorders a very large visible group without overflowing argument limits', () => {
const ids = Array.from({ length: 130_000 }, (_, index) => `wt-${index}`)
const rankByWorktreeId = new Map(
@@ -452,113 +80,3 @@ describe('buildManualOrderUpdatesForVisibleGroups', () => {
expect(Array.from(result.updates)).toEqual([['wt-0', { manualOrder: 0 }]])
})
})
describe('buildManualOrderUpdatesForGroupDrop', () => {
it('moves a selected batch across groups and stamps visible manual order', () => {
const result = buildManualOrderUpdatesForGroupDrop({
groups: [
{ key: 'todo', worktreeIds: ['todo-a', 'todo-b'] },
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b'] }
],
targetGroupKey: 'doing',
draggedIds: ['todo-b'],
dropIndex: 1,
allWorktreeIds: ['todo-a', 'todo-b', 'doing-a', 'doing-b'],
now: 10_000
})
expect(result.changed).toBe(true)
expect(result.orderedIds).toEqual(['todo-a', 'doing-a', 'todo-b', 'doing-b'])
expect(Array.from(result.updates)).toEqual([
['todo-a', { manualOrder: 10_000 }],
['doing-a', { manualOrder: 9000 }],
['todo-b', { manualOrder: 8000 }],
['doing-b', { manualOrder: 7000 }]
])
})
it('keeps visual order for multi-select batches spanning groups', () => {
const result = buildManualOrderUpdatesForGroupDrop({
groups: [
{ key: 'todo', worktreeIds: ['a', 'b'] },
{ key: 'doing', worktreeIds: ['c', 'd'] }
],
targetGroupKey: 'doing',
draggedIds: ['d', 'b'],
dropIndex: 0,
allWorktreeIds: ['a', 'b', 'c', 'd'],
now: 10_000
})
expect(result.orderedIds).toEqual(['a', 'b', 'd', 'c'])
})
it('returns no updates for a no-op same-group drop', () => {
const result = buildManualOrderUpdatesForGroupDrop({
groups: [{ key: 'doing', worktreeIds: ['a', 'b'] }],
targetGroupKey: 'doing',
draggedIds: ['a'],
dropIndex: 1,
allWorktreeIds: ['a', 'b'],
now: 10_000
})
expect(result.changed).toBe(false)
expect(result.updates.size).toBe(0)
})
it('uses sparse moved-row ranks for cross-lane manual drops', () => {
const result = buildManualOrderUpdatesForGroupDrop({
groups: [
{ key: 'todo', worktreeIds: ['todo-a', 'todo-b'] },
{ key: 'doing', worktreeIds: ['doing-a', 'doing-b'] }
],
targetGroupKey: 'doing',
draggedIds: ['todo-b'],
dropIndex: 1,
allWorktreeIds: ['todo-a', 'todo-b', 'doing-a', 'doing-b'],
now: 10_000,
rankByWorktreeId: new Map([
['todo-a', 4000],
['todo-b', 3000],
['doing-a', 2000],
['doing-b', 1000]
])
})
expect(result.orderedIds).toEqual(['todo-a', 'doing-a', 'todo-b', 'doing-b'])
expect(Array.from(result.updates)).toEqual([['todo-b', { manualOrder: 1500 }]])
})
})
describe('shouldWriteManualOrderForGroupDrop', () => {
it('writes order for any lane drop while Manual sort is active', () => {
expect(
shouldWriteManualOrderForGroupDrop({
sortBy: 'manual',
sourceGroupKeys: ['todo'],
targetGroupKey: 'doing'
})
).toBe(true)
})
it('writes order for same-lane drops outside Manual sort', () => {
expect(
shouldWriteManualOrderForGroupDrop({
sortBy: 'recent',
sourceGroupKeys: ['doing', 'doing'],
targetGroupKey: 'doing'
})
).toBe(true)
})
it('keeps cross-lane drops status-only outside Manual sort', () => {
expect(
shouldWriteManualOrderForGroupDrop({
sortBy: 'recent',
sourceGroupKeys: ['todo', 'doing'],
targetGroupKey: 'doing'
})
).toBe(false)
})
})
@@ -1,295 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
getWorktreeSidebarDragAutoscroll,
getWorktreeSidebarBoundaryDrop,
getWorktreeSidebarDragRectsForGroup,
refreshWorktreeSidebarDragSession,
type WorktreeSidebarDragRect,
type WorktreeSidebarDragSession
} from './worktree-sidebar-drag-autoscroll'
const CONTAINER_RECT = {
left: 10,
right: 210,
top: 100,
bottom: 500
}
const SESSION: WorktreeSidebarDragSession = {
draggingWorktreeId: 'b',
sourceGroupKey: 'repo:one',
draggedIds: ['b'],
draggedCopies: [],
reorderDraggedIds: ['b'],
reorderDraggedKeys: ['b'],
reorderUnitDraggedKeys: ['b'],
rects: [{ worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 }],
grab: null,
anchor: null
}
describe('getWorktreeSidebarDragAutoscroll', () => {
it('scrolls up near the top edge', () => {
expect(getAutoscroll({ clientX: 80, clientY: 112 })?.scrollTop).toBeCloseTo(187.93, 2)
})
it('scrolls down near the bottom edge', () => {
expect(getAutoscroll({ clientX: 80, clientY: 488 })?.scrollTop).toBeCloseTo(212.07, 2)
})
it('does nothing away from the vertical edge zones', () => {
expect(getAutoscroll({ clientX: 80, clientY: 300 })).toBeNull()
})
it('does nothing when the pointer is outside horizontally', () => {
expect(getAutoscroll({ clientX: 4, clientY: 488 })).toBeNull()
})
it('does not write past scroll bounds', () => {
expect(getAutoscroll({ clientX: 80, clientY: 100 }, { scrollTop: 0 })).toBeNull()
expect(getAutoscroll({ clientX: 80, clientY: 500 }, { scrollTop: 600 })).toBeNull()
})
it('allows capped scrolling slightly beyond the vertical edge', () => {
expect(getAutoscroll({ clientX: 80, clientY: 530 })?.scrollTop).toBeCloseTo(215.36, 2)
expect(getAutoscroll({ clientX: 80, clientY: 560 })).toBeNull()
})
it('scales by elapsed frame time and clamps delayed frames', () => {
const normal = getAutoscroll({ clientX: 80, clientY: 500 })
const delayed = getAutoscroll({ clientX: 80, clientY: 500 }, { elapsedMs: 200 })
expect(normal?.scrollTop).toBeCloseTo(215.36, 2)
expect(delayed?.scrollTop).toBeCloseTo(230.72, 2)
})
})
describe('getWorktreeSidebarBoundaryDrop', () => {
it('clamps near the group start instead of clearing the edge preview', () => {
expect(
getWorktreeSidebarBoundaryDrop({
localY: 70,
firstRect: { worktreeId: 'a', groupIndex: 0, top: 100, bottom: 140 },
lastRect: { worktreeId: 'c', groupIndex: 2, top: 200, bottom: 240 },
sourceGroupSize: 3
})
).toEqual({ kind: 'drop', dropIndex: 0, indicatorY: 97 })
})
it('clamps near the group end instead of clearing the edge preview', () => {
expect(
getWorktreeSidebarBoundaryDrop({
localY: 270,
firstRect: { worktreeId: 'a', groupIndex: 0, top: 100, bottom: 140 },
lastRect: { worktreeId: 'c', groupIndex: 2, top: 200, bottom: 240 },
sourceGroupSize: 3
})
).toEqual({ kind: 'drop', dropIndex: 3, indicatorY: 243 })
})
it('still rejects gaps that are not the real group edge', () => {
for (const localY of [70, 270]) {
expect(
getWorktreeSidebarBoundaryDrop({
localY,
firstRect: { worktreeId: 'b', groupIndex: 1, top: 100, bottom: 140 },
lastRect: { worktreeId: 'c', groupIndex: 2, top: 200, bottom: 240 },
sourceGroupSize: 4
})
).toEqual({ kind: 'outside' })
}
})
it('keeps normal in-range hover handling unchanged', () => {
expect(
getWorktreeSidebarBoundaryDrop({
localY: 160,
firstRect: { worktreeId: 'a', groupIndex: 0, top: 100, bottom: 140 },
lastRect: { worktreeId: 'c', groupIndex: 2, top: 200, bottom: 240 },
sourceGroupSize: 3
})
).toEqual({ kind: 'inside' })
})
})
describe('getWorktreeSidebarDragRectsForGroup', () => {
it('refreshes mounted rects for the source group only', () => {
const container = makeContainer([
makeDragElement('a', 'repo:one', '0', 140, 180),
makeDragElement('x', 'repo:two', '0', 80, 120),
makeDragElement('b', 'repo:one', '1', 90, 130)
])
expect(getWorktreeSidebarDragRectsForGroup(container, 'repo:one')).toEqual([
{ worktreeId: 'b', groupIndex: 1, top: 40, bottom: 80 },
{ worktreeId: 'a', groupIndex: 0, top: 90, bottom: 130 }
])
})
it('anchors rects to virtual row slots so animated transforms do not perturb hit testing', () => {
const container = makeContainer([
makeDragElement('a', 'repo:one', '0', 320, 360, { start: 240, top: 310 }),
makeDragElement('b', 'repo:one', '1', 380, 420, { start: 298, top: 368 })
])
expect(getWorktreeSidebarDragRectsForGroup(container, 'repo:one')).toEqual([
{ worktreeId: 'a', groupIndex: 0, top: 250, bottom: 290 },
{ worktreeId: 'b', groupIndex: 1, top: 310, bottom: 350 }
])
})
})
describe('refreshWorktreeSidebarDragSession', () => {
it('keeps the dragged set stable while refreshing rects', () => {
const rects: WorktreeSidebarDragRect[] = [
{ worktreeId: 'a', groupIndex: 0, top: 0, bottom: 40 },
{ worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 }
]
expect(
refreshWorktreeSidebarDragSession({
session: SESSION,
groups: [{ key: 'repo:one', worktreeIds: ['a', 'b', 'child'] }],
unitGroups: [
{
key: 'repo:one',
worktreeIds: ['a', 'b'],
units: [
{ worktreeId: 'a', worktreeIds: ['a'] },
{ worktreeId: 'b', worktreeIds: ['b', 'child'] }
]
}
],
rects
})
).toEqual({ ...SESSION, rects })
})
it('clears when the source group is missing', () => {
expect(
refreshWorktreeSidebarDragSession({
session: SESSION,
groups: [{ key: 'repo:two', worktreeIds: ['b'] }],
unitGroups: [{ key: 'repo:one', worktreeIds: ['b'], units: [] }],
rects: []
})
).toBeNull()
})
it('clears when the dragged worktree or reordered unit disappears', () => {
expect(
refreshWorktreeSidebarDragSession({
session: SESSION,
groups: [{ key: 'repo:one', worktreeIds: ['a'] }],
unitGroups: [{ key: 'repo:one', worktreeIds: ['b'], units: [] }],
rects: []
})
).toBeNull()
expect(
refreshWorktreeSidebarDragSession({
session: { ...SESSION, reorderUnitDraggedKeys: ['missing'] },
groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }],
unitGroups: [{ key: 'repo:one', worktreeIds: ['a'], units: [] }],
rects: []
})
).toBeNull()
})
it('keeps a valid session when mounted rects are temporarily empty', () => {
expect(
refreshWorktreeSidebarDragSession({
session: SESSION,
groups: [{ key: 'repo:one', worktreeIds: ['a', 'b'] }],
unitGroups: [{ key: 'repo:one', worktreeIds: ['a', 'b'], units: [] }],
rects: []
})
).toEqual({ ...SESSION, rects: [] })
})
it('keeps child-card reorder drags even when the child is not a top-level unit', () => {
const childSession: WorktreeSidebarDragSession = {
...SESSION,
draggingWorktreeId: 'child',
draggedIds: ['child'],
reorderDraggedIds: ['child'],
reorderDraggedKeys: ['child'],
reorderUnitDraggedKeys: ['child']
}
const rects: WorktreeSidebarDragRect[] = [
{ worktreeId: 'parent', groupIndex: 0, top: 0, bottom: 80 },
{ worktreeId: 'child', groupIndex: 1, top: 88, bottom: 128 },
{ worktreeId: 'sibling', groupIndex: 2, top: 136, bottom: 176 }
]
expect(
refreshWorktreeSidebarDragSession({
session: childSession,
groups: [{ key: 'repo:one', worktreeIds: ['parent', 'child', 'sibling'] }],
unitGroups: [
{
key: 'repo:one',
worktreeIds: ['parent', 'sibling'],
units: [
{ worktreeId: 'parent', worktreeIds: ['parent', 'child'] },
{ worktreeId: 'sibling', worktreeIds: ['sibling'] }
]
}
],
rects
})
).toEqual({ ...childSession, rects })
})
})
function makeContainer(elements: readonly ReturnType<typeof makeDragElement>[]): HTMLElement {
return {
scrollTop: 50,
getBoundingClientRect: () => ({ top: 100 }),
querySelectorAll: () => elements
} as unknown as HTMLElement
}
function getAutoscroll(
point: { clientX: number; clientY: number },
overrides: Partial<Parameters<typeof getWorktreeSidebarDragAutoscroll>[0]> = {}
) {
return getWorktreeSidebarDragAutoscroll({
point,
containerRect: CONTAINER_RECT,
scrollTop: 200,
scrollHeight: 1000,
clientHeight: 400,
elapsedMs: 16,
...overrides
})
}
function makeDragElement(
worktreeId: string,
groupKey: string,
groupIndex: string,
top: number,
bottom: number,
virtualRow?: { start: number; top: number }
): HTMLElement {
const attributes = new Map([
['data-worktree-drag-id', worktreeId],
// Rects key slots by host identity; these fixtures reuse the id string as that key.
['data-worktree-host-identity', worktreeId],
['data-worktree-drag-group-key', groupKey],
['data-worktree-drag-group-index', groupIndex]
])
const virtualRowElement = virtualRow
? ({
getAttribute: (name: string) =>
name === 'data-worktree-virtual-row-start' ? String(virtualRow.start) : null,
getBoundingClientRect: () => ({ top: virtualRow.top })
} as unknown as HTMLElement)
: null
return {
getAttribute: (name: string) => attributes.get(name) ?? null,
getBoundingClientRect: () => ({ top, bottom, height: bottom - top }),
closest: (selector: string) =>
selector === '[data-worktree-virtual-row]' ? virtualRowElement : null
} as unknown as HTMLElement
}
@@ -1,242 +0,0 @@
import { describe, expect, it } from 'vitest'
import { computeWorktreeSidebarDropPreview } from './worktree-sidebar-drop-preview'
import {
getWorktreeSidebarDragGrab,
getWorktreeSidebarDragReferenceY,
resolveWorktreeSidebarDropAnchorIndex,
shouldReevaluateWorktreeSidebarDropAnchor,
type WorktreeSidebarDropAnchor
} from './worktree-sidebar-drag-geometry'
import {
refreshWorktreeSidebarDragSession,
type WorktreeSidebarDragRect
} from './worktree-sidebar-drag-autoscroll'
const GROUP_IDS = ['a', 'b', 'c', 'd', 'e']
const CARD_HEIGHT = 116
const ROW_GAP = 6
// Why: a card with several agent rows expanded runs ~3.5x a collapsed one.
const EXPANDED_CARD_HEIGHT = 404
function layout(heightByWorktreeId: Readonly<Record<string, number>>): WorktreeSidebarDragRect[] {
let top = 0
return GROUP_IDS.map((worktreeId, groupIndex) => {
const height = heightByWorktreeId[worktreeId] ?? CARD_HEIGHT
const rect = { worktreeId, groupIndex, top, bottom: top + height }
top += height + ROW_GAP
return rect
})
}
const COLLAPSED = layout({})
const GRAB = { offsetY: CARD_HEIGHT / 2, height: CARD_HEIGHT }
function previewAt(args: {
pointerY: number
rects: readonly WorktreeSidebarDragRect[]
anchor?: WorktreeSidebarDropAnchor | null
draggingWorktreeId?: string
grab?: { offsetY: number; height: number } | null
}) {
return computeWorktreeSidebarDropPreview({
pointerY: args.pointerY,
containerTop: 0,
scrollTop: 0,
rects: args.rects,
groupIds: GROUP_IDS,
draggedIds: [args.draggingWorktreeId ?? 'a'],
draggingWorktreeId: args.draggingWorktreeId ?? 'a',
grab: args.grab === undefined ? GRAB : args.grab,
anchor: args.anchor
})
}
/** Replay a stationary-pointer drag through card expansion, holding decisions as the live loop does. */
function replayStillPointer(args: {
pointerY: number
frames: readonly (readonly WorktreeSidebarDragRect[])[]
}): { dropIndexes: number[]; indicatorYs: number[] } {
let anchor: WorktreeSidebarDropAnchor | null = null
const dropIndexes: number[] = []
const indicatorYs: number[] = []
for (const rects of args.frames) {
const held = shouldReevaluateWorktreeSidebarDropAnchor({
anchor,
pointerY: args.pointerY,
scrollTop: 0
})
? null
: anchor
const preview = previewAt({ pointerY: args.pointerY, rects, anchor: held })!
anchor = { beforeWorktreeId: preview.dropAnchorId, pointerY: args.pointerY, scrollTop: 0 }
dropIndexes.push(preview.dropIndex)
indicatorYs.push(preview.dropIndicatorY)
}
return { dropIndexes, indicatorYs }
}
describe('worktree sidebar drag geometry under mid-drag card growth', () => {
it('keeps the drop target fixed while a card expands under a still pointer', () => {
const pointerY = 250
const grown = layout({ b: EXPANDED_CARD_HEIGHT })
const unheld = previewAt({ pointerY, rects: grown })!.dropIndex
const { dropIndexes } = replayStillPointer({ pointerY, frames: [COLLAPSED, grown] })
// Re-deciding from the grown layout would move the target with zero input.
expect(unheld).not.toBe(dropIndexes[0])
expect(dropIndexes[1]).toBe(dropIndexes[0])
})
it('never lets a growing card change the drop target across a whole expansion', () => {
const frames = Array.from({ length: 12 }, (_, frame) =>
layout({ b: CARD_HEIGHT + ((EXPANDED_CARD_HEIGHT - CARD_HEIGHT) * frame) / 11 })
)
for (const pointerY of [150, 250, 350, 450, 550]) {
const unheld = frames.map((rects) => previewAt({ pointerY, rects })!.dropIndex)
const { dropIndexes } = replayStillPointer({ pointerY, frames })
expect(new Set(dropIndexes).size).toBe(1)
// The scenario has to be one that actually moves without the hold.
if (pointerY !== 150) {
expect(new Set(unheld).size).toBeGreaterThan(1)
}
}
})
it('slides the indicator with the gap it marks while geometry is held', () => {
const frames = Array.from({ length: 12 }, (_, frame) =>
layout({ b: CARD_HEIGHT + ((EXPANDED_CARD_HEIGHT - CARD_HEIGHT) * frame) / 11 })
)
const { dropIndexes, indicatorYs } = replayStillPointer({ pointerY: 350, frames })
expect(new Set(dropIndexes).size).toBe(1)
// Held decision, live rendering: the line tracks the growing card, never freezes.
expect(indicatorYs.at(-1)!).toBeGreaterThan(indicatorYs[0]!)
expect(new Set(indicatorYs).size).toBe(frames.length)
})
it('still tracks the pointer normally once it moves again', () => {
const grown = layout({ b: EXPANDED_CARD_HEIGHT })
expect(previewAt({ pointerY: 100, rects: grown })!.dropIndex).toBeLessThan(
previewAt({ pointerY: 800, rects: grown })!.dropIndex
)
})
it('re-evaluates on real pointer or scroll movement but not on jitter', () => {
const anchor: WorktreeSidebarDropAnchor = {
beforeWorktreeId: 'c',
pointerY: 250,
scrollTop: 40
}
expect(
shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 250, scrollTop: 40 })
).toBe(false)
expect(
shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 250.2, scrollTop: 40 })
).toBe(false)
expect(
shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 254, scrollTop: 40 })
).toBe(true)
expect(
shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 250, scrollTop: 88 })
).toBe(true)
expect(
shouldReevaluateWorktreeSidebarDropAnchor({ anchor: null, pointerY: 250, scrollTop: 40 })
).toBe(true)
})
it('falls back to a fresh decision when the anchored card disappears mid-drag', () => {
const anchor: WorktreeSidebarDropAnchor = {
beforeWorktreeId: 'gone',
pointerY: 250,
scrollTop: 0
}
expect(resolveWorktreeSidebarDropAnchorIndex({ anchor, groupIds: GROUP_IDS })).toBeNull()
expect(
resolveWorktreeSidebarDropAnchorIndex({
anchor: { beforeWorktreeId: 'c', pointerY: 0, scrollTop: 0 },
groupIds: GROUP_IDS
})
).toBe(2)
// A null anchor id means end-of-group, which survives any row count change.
expect(
resolveWorktreeSidebarDropAnchorIndex({
anchor: { beforeWorktreeId: null, pointerY: 0, scrollTop: 0 },
groupIds: GROUP_IDS
})
).toBe(GROUP_IDS.length)
})
it('keeps one live coordinate space across a session refresh', () => {
const grown = layout({ b: EXPANDED_CARD_HEIGHT })
const refreshed = refreshWorktreeSidebarDragSession({
session: {
draggingWorktreeId: 'a',
sourceGroupKey: 'repo:one',
draggedIds: ['a'],
draggedCopies: [],
reorderDraggedIds: ['a'],
reorderDraggedKeys: ['a'],
reorderUnitDraggedKeys: ['a'],
rects: COLLAPSED,
grab: GRAB,
anchor: null
},
groups: [{ key: 'repo:one', worktreeIds: GROUP_IDS }],
unitGroups: [
{
key: 'repo:one',
worktreeIds: GROUP_IDS,
units: GROUP_IDS.map((worktreeId) => ({ worktreeId, worktreeIds: [worktreeId] }))
}
],
rects: grown
})
expect(refreshed?.rects).toBe(grown)
expect(refreshed?.grab).toBe(GRAB)
})
})
describe('grab-relative hit testing', () => {
it('projects the dragged card from the pointer instead of using the bare pointer', () => {
const activeRect = { worktreeId: 'a', groupIndex: 0, top: 0, bottom: CARD_HEIGHT }
// Grabbed at the very top edge: the card sits below the pointer.
expect(
getWorktreeSidebarDragReferenceY({
localY: 300,
grab: { offsetY: 0, height: CARD_HEIGHT },
activeRect
})
).toBe(300 + CARD_HEIGHT / 2)
// Grabbed at the bottom edge: the card sits above the pointer.
expect(
getWorktreeSidebarDragReferenceY({
localY: 300,
grab: { offsetY: CARD_HEIGHT, height: CARD_HEIGHT },
activeRect
})
).toBe(300 - CARD_HEIGHT / 2)
// No grab (native HTML5 drag) degrades to the raw pointer.
expect(getWorktreeSidebarDragReferenceY({ localY: 300, grab: null, activeRect })).toBe(300)
})
it('clamps a grab offset that lands outside the card', () => {
expect(getWorktreeSidebarDragGrab({ offsetY: -40, height: CARD_HEIGHT })).toEqual({
offsetY: 0,
height: CARD_HEIGHT
})
expect(getWorktreeSidebarDragGrab({ offsetY: 900, height: CARD_HEIGHT })).toEqual({
offsetY: CARD_HEIGHT,
height: CARD_HEIGHT
})
expect(getWorktreeSidebarDragGrab({ offsetY: 10, height: 0 })).toBeNull()
expect(getWorktreeSidebarDragGrab({ offsetY: Number.NaN, height: CARD_HEIGHT })).toBeNull()
})
})