Show full details for child worktree cards

This commit is contained in:
brennanb2025
2026-06-09 17:02:45 -07:00
parent 280db4a1a6
commit 402da49086
4 changed files with 761 additions and 377 deletions
@@ -121,7 +121,11 @@ function RepoIdentityChip({
<TooltipTrigger asChild>
<span
className="inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-worktree-sidebar-border bg-worktree-sidebar-accent/55"
aria-label={translate("auto.components.sidebar.WorktreeCard.35ccfe2475", "Project {{value0}}", { value0: repo.displayName })}
aria-label={translate(
'auto.components.sidebar.WorktreeCard.35ccfe2475',
'Project {{value0}}',
{ value0: repo.displayName }
)}
>
{children}
</span>
@@ -484,6 +488,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
event.stopPropagation()
return
}
if (isDeleting) {
event.preventDefault()
event.stopPropagation()
return
}
// Why: route sidebar clicks through the shared activation path so the
// back/forward stack stays complete for the primary worktree navigation
// surface instead of only recording palette-driven switches.
@@ -494,7 +503,14 @@ const WorktreeCard = React.memo(function WorktreeCard({
}
onActivate?.()
},
[worktree.id, isSshDisconnected, onActivate, onImmediateActivate, onSelectionGesture]
[
worktree.id,
isDeleting,
isSshDisconnected,
onActivate,
onImmediateActivate,
onSelectionGesture
]
)
const handleRenameTitle = useCallback(
@@ -837,7 +853,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/50 backdrop-blur-[1px]">
<div className="inline-flex items-center gap-1.5 rounded-full bg-background px-3 py-1 text-[11px] font-medium text-foreground shadow-sm border border-border/50">
<LoaderCircle className="size-3.5 animate-spin text-muted-foreground" />
{translate("auto.components.sidebar.WorktreeCard.691ccfd622", "Deleting…")}</div>
{translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')}
</div>
</div>
)}
@@ -890,7 +907,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{isSshDisconnected ? translate("auto.components.sidebar.WorktreeCard.021538e1d1", "SSH disconnected") : translate("auto.components.sidebar.WorktreeCard.ca74db7550", "Remote project via SSH")}
{isSshDisconnected
? translate(
'auto.components.sidebar.WorktreeCard.021538e1d1',
'SSH disconnected'
)
: translate(
'auto.components.sidebar.WorktreeCard.ca74db7550',
'Remote project via SSH'
)}
</TooltipContent>
</Tooltip>
)}
@@ -933,13 +958,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
onClick={handleOpenRenameErrorDialog}
onDoubleClick={handleOpenRenameErrorDialog}
className="h-4 shrink-0 gap-0.5 rounded !px-0.5 text-[10px] font-medium leading-none text-destructive border border-destructive/40 bg-destructive/10 hover:bg-destructive/15 hover:text-destructive has-[>svg]:!px-0.5"
aria-label={translate("auto.components.sidebar.WorktreeCard.02e19349f4", "Auto-rename failed: view error")}
aria-label={translate(
'auto.components.sidebar.WorktreeCard.02e19349f4',
'Auto-rename failed: view error'
)}
>
<AlertCircle className="size-2.5" />
{translate("auto.components.sidebar.WorktreeCard.74522ee457", "rename failed")}</Button>
{translate('auto.components.sidebar.WorktreeCard.74522ee457', 'rename failed')}
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{translate("auto.components.sidebar.WorktreeCard.4eba2ea99e", "Auto-name failed. Click to see details.")}</TooltipContent>
{translate(
'auto.components.sidebar.WorktreeCard.4eba2ea99e',
'Auto-name failed. Click to see details.'
)}
</TooltipContent>
</Tooltip>
) : worktree.pendingFirstAgentMessageRename === true && !titleRenaming ? (
<Tooltip>
@@ -951,13 +984,21 @@ const WorktreeCard = React.memo(function WorktreeCard({
onClick={handlePendingFirstAgentMessageRenameInfo}
onDoubleClick={handlePendingFirstAgentMessageRenameInfo}
className="h-4 shrink-0 gap-0.5 rounded !px-0.5 text-[10px] font-medium leading-none text-muted-foreground border border-worktree-sidebar-border/60 bg-worktree-sidebar-accent/45 hover:bg-worktree-sidebar-accent hover:text-foreground has-[>svg]:!px-0.5"
aria-label={translate("auto.components.sidebar.WorktreeCard.c6833b5187", "Will be renamed from first agent message")}
aria-label={translate(
'auto.components.sidebar.WorktreeCard.c6833b5187',
'Will be renamed from first agent message'
)}
>
<Sparkles className="size-2.5" />
{translate("auto.components.sidebar.WorktreeCard.f62a3dadbc", "rename pending")}</Button>
{translate('auto.components.sidebar.WorktreeCard.f62a3dadbc', 'rename pending')}
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{translate("auto.components.sidebar.WorktreeCard.c6833b5187", "Will be renamed from first agent message")}</TooltipContent>
{translate(
'auto.components.sidebar.WorktreeCard.c6833b5187',
'Will be renamed from first agent message'
)}
</TooltipContent>
</Tooltip>
) : null}
@@ -968,10 +1009,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
variant="outline"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 leading-none text-foreground/70 border-foreground/20 bg-foreground/[0.06]"
>
{translate("auto.components.sidebar.WorktreeCard.7d517f82e2", "primary")}</Badge>
{translate('auto.components.sidebar.WorktreeCard.7d517f82e2', 'primary')}
</Badge>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{translate("auto.components.sidebar.WorktreeCard.0777de5970", "Primary worktree (original clone directory)")}</TooltipContent>
{translate(
'auto.components.sidebar.WorktreeCard.0777de5970',
'Primary worktree (original clone directory)'
)}
</TooltipContent>
</Tooltip>
)}
@@ -982,11 +1028,17 @@ const WorktreeCard = React.memo(function WorktreeCard({
variant="outline"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 leading-none text-amber-700 dark:text-amber-300 border-amber-500/30 bg-amber-500/5"
>
{translate("auto.components.sidebar.WorktreeCard.4f964d5e8c", "sparse")}</Badge>
{translate('auto.components.sidebar.WorktreeCard.4f964d5e8c', 'sparse')}
</Badge>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="max-w-72">
<div className="space-y-1">
<div>{translate("auto.components.sidebar.WorktreeCard.0f33af979b", "Partial checkout. Files outside these paths are not on disk.")}</div>
<div>
{translate(
'auto.components.sidebar.WorktreeCard.0f33af979b',
'Partial checkout. Files outside these paths are not on disk.'
)}
</div>
{worktree.sparseDirectories && worktree.sparseDirectories.length > 0 ? (
<div className="font-mono text-[11px] opacity-80">
{formatSparseDirectoryPreview(worktree.sparseDirectories)}
@@ -1017,13 +1069,20 @@ const WorktreeCard = React.memo(function WorktreeCard({
<TooltipTrigger asChild>
<span
className="shrink-0 inline-flex items-center"
aria-label={translate("auto.components.sidebar.WorktreeCard.0d224eff10", "Primary worktree")}
aria-label={translate(
'auto.components.sidebar.WorktreeCard.0d224eff10',
'Primary worktree'
)}
>
<Star className="size-3 fill-amber-400 text-amber-400" />
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{translate("auto.components.sidebar.WorktreeCard.0777de5970", "Primary worktree (original clone directory)")}</TooltipContent>
{translate(
'auto.components.sidebar.WorktreeCard.0777de5970',
'Primary worktree (original clone directory)'
)}
</TooltipContent>
</Tooltip>
)}
@@ -1040,13 +1099,20 @@ const WorktreeCard = React.memo(function WorktreeCard({
'group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',
'text-muted-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive'
)}
aria-label={translate("auto.components.sidebar.WorktreeCard.6f09f58541", "Delete workspace")}
aria-label={translate(
'auto.components.sidebar.WorktreeCard.6f09f58541',
'Delete workspace'
)}
>
<Trash2 className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{translate("auto.components.sidebar.WorktreeCard.6f09f58541", "Delete workspace")}</TooltipContent>
{translate(
'auto.components.sidebar.WorktreeCard.6f09f58541',
'Delete workspace'
)}
</TooltipContent>
</Tooltip>
)}
</div>
@@ -1070,7 +1136,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
variant="secondary"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
>
{repo ? getRepoKindLabel(repo) : translate("auto.components.sidebar.WorktreeCard.93aebe4529", "Folder")}
{repo
? getRepoKindLabel(repo)
: translate('auto.components.sidebar.WorktreeCard.93aebe4529', 'Folder')}
</Badge>
) : showBranch ? (
<span className="min-w-0 text-[11px] text-muted-foreground truncate leading-none">
@@ -1112,7 +1180,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
<div className="mt-0.5 flex items-start gap-1.5 rounded border border-amber-500/25 bg-amber-500/5 px-1.5 py-1 text-[10.5px] leading-snug text-amber-700 dark:text-amber-300">
<AlertTriangle className="mt-[1px] size-3 shrink-0" />
<span className="min-w-0 flex-1">
{remoteBranchConflict.remote}/{remoteBranchConflict.branchName} {translate("auto.components.sidebar.WorktreeCard.a88c92d0e3", "already exists.")}</span>
{remoteBranchConflict.remote}/{remoteBranchConflict.branchName}{' '}
{translate('auto.components.sidebar.WorktreeCard.a88c92d0e3', 'already exists.')}
</span>
</div>
)}
@@ -1159,7 +1229,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{lineageCollapsed ? translate("auto.components.sidebar.WorktreeCard.8cb634cda6", "Show child workspaces") : translate("auto.components.sidebar.WorktreeCard.57eaa61b55", "Hide child workspaces")}
{lineageCollapsed
? translate(
'auto.components.sidebar.WorktreeCard.8cb634cda6',
'Show child workspaces'
)
: translate(
'auto.components.sidebar.WorktreeCard.57eaa61b55',
'Hide child workspaces'
)}
</TooltipContent>
</Tooltip>
</div>
@@ -59,25 +59,75 @@ vi.mock('./project-header-drag', () => ({
vi.mock('./WorktreeCard', () => ({
default: ({
worktree,
repo,
isActive,
contentIndent,
flushSurface,
lineageChildCount,
lineageCollapsed,
lineageChildren
}: {
worktree: Worktree
repo?: Repo
isActive?: boolean
contentIndent?: number
flushSurface?: boolean
lineageChildCount?: number
lineageCollapsed?: boolean
lineageChildren?: React.ReactNode
}) =>
React.createElement(
}) => {
const deleteStateByWorktreeId =
(mockStore.state.deleteStateByWorktreeId as Record<
string,
{ isDeleting?: boolean } | undefined
>) ?? {}
const cardProps = (mockStore.state.worktreeCardProperties as string[] | undefined) ?? []
const sshState =
repo?.connectionId && mockStore.state.sshConnectionStates instanceof Map
? mockStore.state.sshConnectionStates.get(repo.connectionId)
: null
const isDeleting = deleteStateByWorktreeId[worktree.id]?.isDeleting === true
const showSshDialog = isActive && repo?.connectionId && sshState?.status !== 'connected'
return React.createElement(
'section',
{
'data-worktree-card-id': worktree.id,
'data-worktree-card-active': isActive ? 'true' : undefined,
'data-content-indent': contentIndent,
'data-flush-surface': flushSurface ? 'true' : undefined
'data-flush-surface': flushSurface ? 'true' : undefined,
'data-lineage-child-count': lineageChildCount,
'data-lineage-collapsed':
lineageCollapsed === undefined ? undefined : String(lineageCollapsed),
'data-linked-pr': worktree.linkedPR ?? undefined,
'data-linked-gitlab-mr': worktree.linkedGitLabMR ?? undefined,
'aria-busy': isDeleting ? 'true' : undefined
},
React.createElement('h2', null, worktree.displayName),
isDeleting ? React.createElement('span', null, 'Deleting') : null,
cardProps.includes('unread') && worktree.isUnread
? React.createElement('button', { 'aria-label': 'Mark as read' }, 'Unread')
: null,
lineageChildCount
? React.createElement(
'button',
{
'data-lineage-toggle-for': worktree.id,
'aria-expanded': lineageCollapsed ? 'false' : 'true'
},
`${lineageChildCount} ${lineageChildCount === 1 ? 'child' : 'children'}`
)
: null,
showSshDialog
? React.createElement('aside', {
'data-worktree-card-ssh-dialog': 'open',
'data-ssh-status': sshState?.status ?? 'disconnected',
'data-ssh-target-id': repo?.connectionId
})
: null,
lineageChildren
)
}
}))
vi.mock('./WorktreeCardAgents', () => ({
@@ -206,6 +256,7 @@ function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage {
function setLineageFixtureState(
groupBy: 'none' | 'repo' = 'none',
options: {
childWorktreeOverrides?: Partial<Worktree>
deletingWorktreeIds?: string[]
projectGrouped?: boolean
unreadWorktreeIds?: string[]
@@ -241,6 +292,7 @@ function setLineageFixtureState(
branch: 'child-branch',
sortOrder: 20
})
Object.assign(child, options.childWorktreeOverrides)
const grandchild = makeWorktree({
id: 'grandchild',
instanceId: 'grandchild-instance',
@@ -453,6 +505,18 @@ async function renderWorktreeListMarkup(): Promise<string> {
)
}
function getCardOpeningTag(markup: string, worktreeId: string): string {
return (
markup.match(new RegExp(`<section[^>]*data-worktree-card-id="${worktreeId}"[^>]*>`))?.[0] ?? ''
)
}
function getOptionOpeningTag(markup: string, worktreeId: string): string {
return (
markup.match(new RegExp(`<div[^>]*id="worktree-list-option-${worktreeId}"[^>]*>`))?.[0] ?? ''
)
}
describe('WorktreeList lineage child card renderer', () => {
beforeAll(async () => {
WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent
@@ -492,63 +556,76 @@ describe('WorktreeList lineage child card renderer', () => {
expect(markup).not.toContain('empty-project')
})
it('renders nested inline agent rows before the nested child-count toggle', async () => {
it('renders recursive lineage descendants through WorktreeCard once', async () => {
setLineageFixtureState()
const markup = await renderWorktreeListMarkup()
const childStart = markup.indexOf('lineage child with agent')
const agentRowIndex = markup.indexOf('Review fixture prompt', childStart)
const childToggleIndex = markup.indexOf('1 child', childStart)
expect(markup.match(/data-worktree-card-id="parent"/g)).toHaveLength(1)
expect(markup.match(/data-worktree-card-id="child"/g)).toHaveLength(1)
expect(markup.match(/data-worktree-card-id="grandchild"/g)).toHaveLength(1)
expect(childStart).toBeGreaterThan(-1)
expect(agentRowIndex).toBeGreaterThan(childStart)
expect(childToggleIndex).toBeGreaterThan(childStart)
expect(agentRowIndex).toBeLessThan(childToggleIndex)
const parentIndex = markup.indexOf('data-worktree-card-id="parent"')
const childIndex = markup.indexOf('data-worktree-card-id="child"')
const grandchildIndex = markup.indexOf('data-worktree-card-id="grandchild"')
expect(parentIndex).toBeGreaterThan(-1)
expect(childIndex).toBeGreaterThan(parentIndex)
expect(grandchildIndex).toBeGreaterThan(childIndex)
expect(getCardOpeningTag(markup, 'child')).toContain('data-lineage-child-count="1"')
})
it('renders nested child titles through the inline rename surface', async () => {
setLineageFixtureState()
it('passes child review details through the shared WorktreeCard path', async () => {
setLineageFixtureState('none', {
childWorktreeOverrides: { linkedPR: 456, linkedGitLabMR: 42 }
})
const markup = await renderWorktreeListMarkup()
const childCard = getCardOpeningTag(markup, 'child')
expect(markup).toContain('data-worktree-title-inline-rename=""')
expect(markup).toContain('lineage child with agent')
expect(childCard).toContain('data-linked-pr="456"')
expect(childCard).toContain('data-linked-gitlab-mr="42"')
})
it('nests the first-level child workspace card surface under its parent', async () => {
it('uses shared nested-row indentation for child and grandchild cards', async () => {
setLineageFixtureState()
const markup = await renderWorktreeListMarkup()
expect(markup).toContain('<div style="padding-left:14px"><div id="worktree-list-option-child"')
expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px')
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="0"')
expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"')
expect(getOptionOpeningTag(markup, 'grandchild')).toContain('padding-left:28px')
expect(getCardOpeningTag(markup, 'grandchild')).toContain('data-content-indent="0"')
expect(getCardOpeningTag(markup, 'grandchild')).toContain('data-flush-surface="true"')
})
it('shows deleting feedback on nested lineage child cards', async () => {
setLineageFixtureState('none', { deletingWorktreeIds: ['child'] })
const markup = await renderWorktreeListMarkup()
const childCard =
markup.match(/<div id="worktree-list-option-child"[\s\S]*?lineage child with agent/)?.[0] ??
''
const childCard = getCardOpeningTag(markup, 'child')
const childIndex = markup.indexOf('data-worktree-card-id="child"')
const childMarkup = markup.slice(
childIndex,
markup.indexOf('data-worktree-card-id="grandchild"')
)
expect(childCard).toContain('aria-busy="true"')
expect(childCard).toContain('cursor-not-allowed opacity-50 grayscale')
expect(childCard).toContain('animate-spin')
expect(childCard).toContain('Deleting')
expect(childMarkup).toContain('Deleting')
})
it('shows the unread bell action on unread nested lineage child cards', async () => {
setLineageFixtureState('none', { unreadWorktreeIds: ['child'] })
mockStore.state.worktreeCardProperties = ['status', 'unread', 'inline-agents']
const markup = await renderWorktreeListMarkup()
const childIndex = markup.indexOf('data-worktree-card-id="child"')
const childMarkup = markup.slice(
childIndex,
markup.indexOf('data-worktree-card-id="grandchild"')
)
const childCard =
markup.match(/<div id="worktree-list-option-child"[\s\S]*?lineage child with agent/)?.[0] ??
''
expect(childCard).toContain('aria-label="Mark as read"')
expect(childCard).not.toContain('aria-label="Mark as unread"')
expect(childMarkup).toContain('aria-label="Mark as read"')
expect(childMarkup).not.toContain('aria-label="Mark as unread"')
})
it('opens the reconnect dialog for an active disconnected lineage child during render', async () => {
it('lets WorktreeCard own the reconnect dialog for an active disconnected lineage child', async () => {
setLineageFixtureState()
const repo = (mockStore.state.repos as Repo[])[0]!
repo.connectionId = 'ssh-target-1'
@@ -558,17 +635,18 @@ describe('WorktreeList lineage child card renderer', () => {
const markup = await renderWorktreeListMarkup()
expect(markup).toContain('data-lineage-ssh-dialog="open"')
expect(getCardOpeningTag(markup, 'child')).toContain('data-worktree-card-active="true"')
expect(markup).toContain('data-worktree-card-ssh-dialog="open"')
expect(markup).not.toContain('data-lineage-ssh-dialog="open"')
expect(markup).toContain('data-ssh-status="disconnected"')
expect(markup).toContain('data-ssh-target-id="ssh-target-1"')
expect(markup).toContain('data-ssh-target-label="Remote target"')
})
it('does not add group indentation when grouping is disabled', async () => {
setLineageFixtureState('none')
const markup = await renderWorktreeListMarkup()
const parentRow = markup.match(/<div[^>]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
const parentRow = getOptionOpeningTag(markup, 'parent')
expect(parentRow).toContain('id="worktree-list-option-parent"')
expect(parentRow).not.toContain('padding-left')
@@ -578,23 +656,39 @@ describe('WorktreeList lineage child card renderer', () => {
setLineageFixtureState('repo')
const markup = await renderWorktreeListMarkup()
const parentRow = markup.match(/<div[^>]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
const parentRow = getOptionOpeningTag(markup, 'parent')
expect(parentRow).not.toContain('padding-left')
expect(markup).toContain(
'<section data-worktree-card-id="parent" data-content-indent="20" data-flush-surface="true">'
)
expect(getCardOpeningTag(markup, 'parent')).toContain('data-content-indent="20"')
expect(getCardOpeningTag(markup, 'parent')).toContain('data-flush-surface="true"')
})
it('keeps nested card inner padding aligned with grouped parent cards', async () => {
setLineageFixtureState('repo')
const markup = await renderWorktreeListMarkup()
expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px')
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="20"')
expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"')
})
it('keeps nested card inner padding aligned inside project groups', async () => {
setLineageFixtureState('repo', { projectGrouped: true })
const markup = await renderWorktreeListMarkup()
expect(getOptionOpeningTag(markup, 'child')).toContain('padding-left:14px')
expect(getCardOpeningTag(markup, 'child')).toContain('data-content-indent="38"')
expect(getCardOpeningTag(markup, 'child')).toContain('data-flush-surface="true"')
})
it('adds project group depth to workspace card content indentation', async () => {
setLineageFixtureState('repo', { projectGrouped: true })
const markup = await renderWorktreeListMarkup()
const parentRow = markup.match(/<div[^>]*id="worktree-list-option-parent"[^>]*>/)?.[0] ?? ''
const parentRow = getOptionOpeningTag(markup, 'parent')
expect(parentRow).not.toContain('padding-left')
expect(markup).toContain(
'<section data-worktree-card-id="parent" data-content-indent="38" data-flush-surface="true">'
)
expect(getCardOpeningTag(markup, 'parent')).toContain('data-content-indent="38"')
expect(getCardOpeningTag(markup, 'parent')).toContain('data-flush-surface="true"')
})
})
@@ -0,0 +1,392 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type {
Repo,
Worktree,
WorktreeCardProperty,
WorktreeLineage
} from '../../../../shared/types'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const mockStore = vi.hoisted(() => ({
state: {} as Record<string, unknown>,
activateWorktreeFromSidebar: vi.fn(),
openModal: vi.fn(),
updateWorktreeMeta: vi.fn(),
fetchHostedReviewForBranch: vi.fn(),
fetchIssue: vi.fn(),
fetchLinearIssue: vi.fn(),
openTaskPage: vi.fn()
}))
type WorktreeListComponent = React.ComponentType<{
scrollOffsetRef: React.RefObject<number>
scrollAnchorRef: React.RefObject<unknown>
}>
let WorktreeList: WorktreeListComponent
vi.mock('@/store', () => {
const useAppStore = ((selector: (state: Record<string, unknown>) => unknown) =>
selector(mockStore.state)) as ((
selector: (state: Record<string, unknown>) => unknown
) => unknown) & {
getState: () => Record<string, unknown>
}
useAppStore.getState = () => mockStore.state
return { useAppStore }
})
vi.mock('@tanstack/react-virtual', () => ({
defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) =>
Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index),
measureElement: () => 32,
useVirtualizer: ({ count }: { count: number }) => ({
elementsCache: new Map(),
getTotalSize: () => count * 96,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: `row-${index}`,
start: index * 96
})),
measureElement: vi.fn(),
scrollToIndex: vi.fn()
})
}))
vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({
VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor',
useVirtualizedScrollAnchor: vi.fn()
}))
vi.mock('./project-header-drag', () => ({
useRepoHeaderDrag: () => ({
state: { draggingRepoId: null, dropIndicatorY: null },
onHandlePointerDown: vi.fn()
}),
isRepoHeaderActionTarget: () => false
}))
vi.mock('@/components/ui/hover-card', () => ({
HoverCard: ({ children }: { children: ReactNode }) => <>{children}</>,
HoverCardContent: ({ children }: { children: ReactNode }) => (
<div data-hover-card-content="">{children}</div>
),
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('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
<button onClick={onSelect}>{children}</button>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/lib/sidebar-worktree-activation', () => ({
activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
getActiveRuntimeTarget: () => ({ kind: 'local' }),
callRuntimeRpc: vi.fn()
}))
vi.mock('./CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownStartedAt: () => null
}))
vi.mock('./WorktreeCardAgents', () => ({
default: ({ worktreeId }: { worktreeId: string }) => (
<div data-agent-worktree-id={worktreeId}>Agent row</div>
),
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment'
}))
vi.mock('./SshDisconnectedDialog', () => ({
SshDisconnectedDialog: () => null
}))
vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus',
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: '/tmp/lineage-real-card',
displayName: 'lineage-real-card',
badgeColor: '#999999',
addedAt: 1
}
}
function makeWorktree(args: {
id: string
displayName: string
branch: string
sortOrder: number
instanceId: string
overrides?: Partial<Worktree>
}): Worktree {
return {
id: args.id,
instanceId: args.instanceId,
repoId: 'repo-1',
path: `/tmp/lineage-real-card/${args.id}`,
displayName: args.displayName,
branch: args.branch,
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: args.sortOrder,
lastActivityAt: args.sortOrder,
...args.overrides
}
}
function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage {
return {
worktreeId: worktree.id,
worktreeInstanceId: worktree.instanceId!,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId!,
origin: 'orchestration',
capture: { source: 'orchestration-context', confidence: 'explicit' },
createdAt: 1
}
}
function makeHostedReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
return {
provider: 'gitlab',
number: 42,
title: 'Child GitLab MR',
state: 'open',
url: 'https://gitlab.com/acme/orca/-/merge_requests/42',
status: 'success',
updatedAt: '2026-06-09T00:00:00.000Z',
mergeable: 'MERGEABLE',
...overrides
}
}
function setLineageState(options: { deletingChild?: boolean } = {}): void {
const repo = makeRepo()
const parent = makeWorktree({
id: 'parent',
instanceId: 'parent-instance',
displayName: 'lineage parent',
branch: 'parent-branch',
sortOrder: 20
})
const child = makeWorktree({
id: 'child',
instanceId: 'child-instance',
displayName: 'lineage child',
branch: 'child-branch',
sortOrder: 10,
overrides: {
linkedGitLabMR: 42,
comment: 'Child handoff note'
}
})
mockStore.state = {
activeModal: '',
activeView: 'terminal',
activeWorktreeId: null,
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
browserTabsByWorktree: {},
clearPendingRevealWorktreeId: vi.fn(),
collapsedGroups: new Set<string>(),
deleteStateByWorktreeId: options.deletingChild
? { [child.id]: { isDeleting: true, error: null, canForceDelete: false } }
: {},
detectedWorktreesByRepo: {},
fetchHostedReviewForBranch: mockStore.fetchHostedReviewForBranch,
fetchIssue: mockStore.fetchIssue,
fetchLinearIssue: mockStore.fetchLinearIssue,
filterRepoIds: [],
gitConflictOperationByWorktree: {},
groupBy: 'none',
hideDefaultBranchWorkspace: false,
hostedReviewCache: {
'local::repo-1::child-branch': {
data: makeHostedReview(),
fetchedAt: Date.now(),
linkedReviewHintKey: 'gitlab:42'
}
},
issueCache: {},
linearIssueCache: {},
linearStatus: null,
migrationUnsupportedByPtyId: {},
openModal: mockStore.openModal,
openSettingsPage: vi.fn(),
openSettingsTarget: null,
openTaskPage: mockStore.openTaskPage,
pendingRevealWorktree: null,
prCache: {},
projectGroups: [],
ptyIdsByTabId: {},
recordFeatureInteraction: vi.fn(),
remoteBranchConflictByWorktreeId: {},
reorderRepos: vi.fn(),
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
repos: [repo],
retainedAgentsByPaneKey: {},
revealWorktreeInSidebar: vi.fn(),
runtimePaneTitlesByTabId: {},
setFilterRepoIds: vi.fn(),
setHideDefaultBranchWorkspace: vi.fn(),
setRenamingWorktreeId: vi.fn(),
setShowSleepingWorkspaces: vi.fn(),
setSortBy: vi.fn(),
setWorktreesPinnedAndReveal: vi.fn(),
settings: null,
showSleepingWorkspaces: true,
sortBy: 'manual',
sortEpoch: 0,
sshConnectedGeneration: 0,
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
tabsByWorktree: {},
terminalLayoutsByTabId: {},
toggleCollapsedGroup: vi.fn(),
updateRepo: vi.fn(),
updateWorktreeMeta: mockStore.updateWorktreeMeta,
updateWorktreesMeta: vi.fn(),
workspacePortScan: null,
workspaceStatuses: [],
worktreeCardProperties: [
'status',
'pr',
'comment',
'inline-agents'
] satisfies WorktreeCardProperty[],
worktreeLineageById: {
[child.id]: makeLineage(child, parent)
},
worktreesByRepo: {
[repo.id]: [parent, child]
}
}
}
const mountedRoots: Root[] = []
async function renderWorktreeList(): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mountedRoots.push(root)
await act(async () => {
root.render(
<WorktreeList scrollOffsetRef={{ current: 0 }} scrollAnchorRef={{ current: null }} />
)
})
return container
}
describe('WorktreeList real child WorktreeCard integration', () => {
beforeAll(async () => {
WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent
}, 20_000)
beforeEach(() => {
vi.clearAllMocks()
setLineageState()
})
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) {
root.unmount()
}
})
document.body.innerHTML = ''
})
it('renders GitLab MR metadata from a child through the real WorktreeCard path', async () => {
const container = await renderWorktreeList()
const childOption = container.querySelector('#worktree-list-option-child')
expect(childOption?.textContent).toContain('MR #42')
expect(childOption?.textContent).toContain('Child GitLab MR')
expect(childOption?.textContent).toContain('Child handoff note')
})
it('double-clicking a nested child opens edit metadata for the child only', async () => {
const container = await renderWorktreeList()
const childCard = container.querySelector<HTMLElement>(
'#worktree-list-option-child [data-worktree-card-surface="true"]'
)
expect(childCard).not.toBeNull()
await act(async () => {
childCard!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
})
expect(mockStore.openModal).toHaveBeenCalledTimes(1)
expect(mockStore.openModal).toHaveBeenCalledWith(
'edit-meta',
expect.objectContaining({
worktreeId: 'child',
currentDisplayName: 'lineage child',
currentComment: 'Child handoff note'
})
)
expect(mockStore.openModal).not.toHaveBeenCalledWith(
'edit-meta',
expect.objectContaining({ worktreeId: 'parent' })
)
})
it('does not activate a nested child while it is deleting', async () => {
setLineageState({ deletingChild: true })
const container = await renderWorktreeList()
const childCard = container.querySelector<HTMLElement>(
'#worktree-list-option-child [data-worktree-card-surface="true"]'
)
expect(childCard?.textContent).toContain('Deleting')
await act(async () => {
childCard!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mockStore.activateWorktreeFromSidebar).not.toHaveBeenCalled()
})
})
@@ -12,12 +12,10 @@ import {
Eye,
FolderInput,
FolderPlus,
Loader2,
Plus,
Shapes,
SlidersHorizontal,
Trash2,
Workflow
Trash2
} from 'lucide-react'
import { useAppStore } from '@/store'
import { useShallow } from 'zustand/react/shallow'
@@ -30,12 +28,7 @@ import {
} from '@/store/selectors'
import WorktreeCard from './WorktreeCard'
import { PendingWorktreeRow } from './PendingWorktreeRow'
import WorktreeCardAgents, {
SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT
} from './WorktreeCardAgents'
import { WorktreeTitleInlineRename } from './WorktreeTitleInlineRename'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot'
import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
@@ -117,14 +110,12 @@ import {
type VirtualizedScrollAnchor
} from '@/hooks/useVirtualizedScrollAnchor'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import {
SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT,
type ScrollToCurrentWorkspaceRevealRequestDetail
} from '@/lib/scroll-to-current-workspace-status'
import { isRepoHeaderActionTarget, useRepoHeaderDrag } from './project-header-drag'
import WorktreeContextMenu from './WorktreeContextMenu'
import {
buildManualOrderUpdatesForGroupDrop,
buildManualOrderUpdatesForVisibleGroups,
@@ -176,7 +167,6 @@ import {
pruneWorktreeSelection,
updateWorktreeSelection
} from './worktree-multi-selection'
import { branchDisplayName } from './WorktreeCardHelpers'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getRepoHeaderCreateState } from './repo-header-create-state'
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
@@ -191,7 +181,6 @@ import {
} from '../../../../shared/worktree-ownership'
import { RepoIconGlyph } from '@/components/repo/repo-icon'
import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import ImportedWorktreesVisibilityLine from './ImportedWorktreesVisibilityLine'
import {
keepImportedWorktreesHiddenCard,
@@ -362,10 +351,10 @@ function getWorktreeVisibilityMenuLabel(repo: Repo): string {
return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees'
}
// Why: child workspace cards are already nested inside the parent card body;
// using the full tree step makes the second-level card drift too far right.
const LINEAGE_INDENT = 14
const SIDEBAR_POINTER_DRAG_THRESHOLD_PX = 4
// Why: nested child worktree cards sit inside the parent card body. Preserve
// the legacy lineage surface offset instead of using the full sidebar tree step.
const NESTED_LINEAGE_CARD_INDENT = 14
type VirtualizedWorktreeViewportProps = {
rows: Row[]
@@ -396,7 +385,6 @@ type VirtualizedWorktreeViewportProps = {
selectedWorktrees: readonly Worktree[]
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktreeId: string) => boolean
onImmediateWorktreeActivate: (worktreeId: string) => void
onToggleWorktreeUnread: (worktree: Worktree) => void
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
@@ -439,7 +427,6 @@ type VirtualizedWorktreeViewportProps = {
draggedIds: readonly string[]
dropIndex: number
}) => void
showInlineAgentCards: boolean
// Why: broad grouping changes still remount the viewport, while add/delete
// stays mounted for row-key anchoring and layout animation. These refs bridge
// both paths so the virtualizer never falls back to scrollTop 0.
@@ -744,7 +731,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
selectedWorktrees,
onSelectionGesture,
onImmediateWorktreeActivate,
onToggleWorktreeUnread,
onContextMenuSelect,
repoMap,
worktreeMap,
@@ -762,7 +748,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onDropWorktreesOnWorkspaceBoard,
shouldShowWorkspaceBoardDropIndicator,
onReorderWorktrees,
showInlineAgentCards,
scrollOffsetRef,
scrollAnchorRef
}: VirtualizedWorktreeViewportProps) {
@@ -771,7 +756,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const directScrollInputUntilRef = useRef(0)
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
const [pinDragOver, setPinDragOver] = useState(false)
const [lineageReconnectWorktreeId, setLineageReconnectWorktreeId] = useState<string | null>(null)
const [worktreeDragState, setWorktreeDragState] = useState<WorktreeRowDragState>(
WORKTREE_ROW_DRAG_INITIAL_STATE
)
@@ -780,9 +764,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState<string | null>(
null
)
const renamingWorktreeId = useAppStore((s) => s.renamingWorktreeId)
const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId)
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
const worktreeDragSessionRef = useRef<WorktreeSidebarDragSession | null>(null)
const worktreePointerDragRef = useRef<WorktreePointerDrag | null>(null)
const worktreePointerAutoscrollFrameIdRef = useRef<number | null>(null)
@@ -851,7 +833,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration)
const settings = useAppStore((s) => s.settings)
const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId)
const reorderRepos = useAppStore((s) => s.reorderRepos)
useEffect(
@@ -1015,41 +996,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
() => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)),
[renderRows, activeWorktreeId]
)
const activeLineageChildRow = useMemo(() => {
if (activeWorktreeId === null) {
return null
}
for (const row of renderRows) {
if (row.type !== 'lineage-group') {
continue
}
const child = row.rows.slice(1).find((item) => item.worktree.id === activeWorktreeId)
if (child) {
return child
}
}
return null
}, [activeWorktreeId, renderRows])
const activeLineageChildWorktreeId = activeLineageChildRow?.worktree.id ?? null
const activeLineageChildConnectionId = activeLineageChildRow?.repo?.connectionId ?? null
const activeLineageChildSshStatus = useAppStore((s) =>
activeLineageChildConnectionId
? (s.sshConnectionStates.get(activeLineageChildConnectionId)?.status ?? 'disconnected')
: null
)
const activeLineageChildTargetLabel = useAppStore((s) =>
activeLineageChildConnectionId ? s.sshTargetLabels.get(activeLineageChildConnectionId) : null
)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const activeLineageChildSshDisconnected =
activeLineageChildSshStatus !== null && activeLineageChildSshStatus !== 'connected'
const lineageReconnectPromptKey =
activeLineageChildWorktreeId && activeLineageChildSshDisconnected
? activeLineageChildWorktreeId
: null
const [lastLineageReconnectPromptKey, setLastLineageReconnectPromptKey] = useState<string | null>(
null
)
const renderRowsRef = useRef(renderRows)
renderRowsRef.current = renderRows
const getVirtualItemKey = useCallback(
@@ -1536,16 +1483,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
}, [activeModal, keybindings, markDirectScrollInput, navigateWorktree])
// Why: lightweight nested cards do not mount WorktreeCard, so the viewport
// owns the SSH reconnect prompt for an active lineage child. The prompt key
// keeps dismissals sticky until the active/disconnected child changes.
if (lineageReconnectPromptKey !== lastLineageReconnectPromptKey) {
setLastLineageReconnectPromptKey(lineageReconnectPromptKey)
if (lineageReconnectPromptKey) {
setLineageReconnectWorktreeId(lineageReconnectPromptKey)
}
}
const handleContainerKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
@@ -2753,7 +2690,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
data-worktree-sidebar
tabIndex={0}
role="listbox"
aria-label={translate("auto.components.sidebar.WorktreeList.bfbedc547b", "Worktrees")}
aria-label={translate('auto.components.sidebar.WorktreeList.bfbedc547b', 'Worktrees')}
aria-orientation="vertical"
aria-multiselectable="true"
aria-activedescendant={activeDescendantId}
@@ -2770,24 +2707,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
className="worktree-sidebar-scrollbar h-full overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px"
style={WORKTREE_SIDEBAR_SCROLL_STYLE}
>
{activeLineageChildConnectionId && activeLineageChildSshStatus ? (
<SshDisconnectedDialog
open={
lineageReconnectWorktreeId === activeLineageChildWorktreeId &&
activeLineageChildSshDisconnected
}
onOpenChange={(open) => {
if (!open) {
setLineageReconnectWorktreeId(null)
}
}}
targetId={activeLineageChildConnectionId}
targetLabel={
activeLineageChildTargetLabel ?? activeLineageChildRow?.repo?.displayName ?? ''
}
status={activeLineageChildSshStatus}
/>
) : null}
<div
role="presentation"
className="relative w-full"
@@ -3006,7 +2925,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
size="icon-xs"
data-repo-header-action=""
className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={translate("auto.components.sidebar.WorktreeList.79465e9034", "Group actions for {{value0}}", { value0: row.label })}
aria-label={translate(
'auto.components.sidebar.WorktreeList.79465e9034',
'Group actions for {{value0}}',
{ value0: row.label }
)}
onClick={(event) => event.stopPropagation()}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
@@ -3035,7 +2958,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
}}
>
{translate("auto.components.sidebar.WorktreeList.4d7b73658c", "Rename group")}</DropdownMenuItem>
{translate(
'auto.components.sidebar.WorktreeList.4d7b73658c',
'Rename group'
)}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
@@ -3044,12 +2971,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
}}
>
{translate("auto.components.sidebar.WorktreeList.902115cdbe", "Delete group")}</DropdownMenuItem>
{translate(
'auto.components.sidebar.WorktreeList.902115cdbe',
'Delete group'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{row.repo && groupBy === "repo" ? (
{row.repo && groupBy === 'repo' ? (
<DropdownMenu modal={false}>
<Tooltip>
<TooltipTrigger asChild>
@@ -3060,7 +2991,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
size="icon-xs"
data-repo-header-action=""
className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={translate("auto.components.sidebar.WorktreeList.609633a9e6", "Project actions for {{value0}}", { value0: row.label })}
aria-label={translate(
'auto.components.sidebar.WorktreeList.609633a9e6',
'Project actions for {{value0}}',
{ value0: row.label }
)}
onClick={(event) => event.stopPropagation()}
onKeyDown={stopRepoHeaderKeyboardToggle}
onPointerDown={handleRepoHeaderActionPointerDown}
@@ -3070,7 +3005,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate("auto.components.sidebar.WorktreeList.2ef41bf9a7", "Project actions")}</TooltipContent>
{translate(
'auto.components.sidebar.WorktreeList.2ef41bf9a7',
'Project actions'
)}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
@@ -3094,7 +3033,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}}
>
<SlidersHorizontal className="size-3.5" />
{translate("auto.components.sidebar.WorktreeList.2cdffbc728", "Project Settings")}</DropdownMenuItem>
{translate(
'auto.components.sidebar.WorktreeList.2cdffbc728',
'Project Settings'
)}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
if (row.repo) {
@@ -3106,7 +3049,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}}
>
<Shapes className="size-3.5" />
{translate("auto.components.sidebar.WorktreeList.e82d3589a1", "Change Project Icon")}</DropdownMenuItem>
{translate(
'auto.components.sidebar.WorktreeList.e82d3589a1',
'Change Project Icon'
)}
</DropdownMenuItem>
{row.repo && isGitRepoKind(row.repo) ? (
<DropdownMenuItem
onSelect={() => {
@@ -3127,12 +3074,20 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}}
>
<FolderPlus className="size-3.5" />
{translate("auto.components.sidebar.WorktreeList.cbfd565f83", "New group from project")}</DropdownMenuItem>
{translate(
'auto.components.sidebar.WorktreeList.cbfd565f83',
'New group from project'
)}
</DropdownMenuItem>
{projectGroups.length > 0 ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="size-3.5" />
{translate("auto.components.sidebar.WorktreeList.4a08fb55f2", "Move to group")}</DropdownMenuSubTrigger>
{translate(
'auto.components.sidebar.WorktreeList.4a08fb55f2',
'Move to group'
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{projectGroups.map((group) => (
<DropdownMenuItem
@@ -3159,7 +3114,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}}
>
<CircleX className="size-3.5" />
{translate("auto.components.sidebar.WorktreeList.64e55f7f01", "Remove from group")}</DropdownMenuItem>
{translate(
'auto.components.sidebar.WorktreeList.64e55f7f01',
'Remove from group'
)}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -3171,12 +3130,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}}
>
<Trash2 className="size-3.5" />
{translate("auto.components.sidebar.WorktreeList.c83968f87f", "Remove Project")}</DropdownMenuItem>
{translate(
'auto.components.sidebar.WorktreeList.c83968f87f',
'Remove Project'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{row.repo && groupBy === "repo" ? (
{row.repo && groupBy === 'repo' ? (
<Tooltip>
<TooltipTrigger asChild>
{createState?.disabled ? (
@@ -3208,7 +3171,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
data-repo-header-action=""
className="size-5 shrink-0 rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-accent/70 hover:text-foreground focus:opacity-100 group-hover:opacity-100"
aria-label={
createState?.ariaLabel ?? translate("auto.components.sidebar.WorktreeList.bb85cd86ba", "Create workspace for {{value0}}", { value0: row.label })
createState?.ariaLabel ??
translate(
'auto.components.sidebar.WorktreeList.bb85cd86ba',
'Create workspace for {{value0}}',
{ value0: row.label }
)
}
onKeyDown={stopRepoHeaderKeyboardToggle}
onClick={(event) => {
@@ -3224,7 +3192,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)}
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{createState?.tooltip ?? translate("auto.components.sidebar.WorktreeList.bb85cd86ba", "Create workspace for {{value0}}", { value0: row.label })}
{createState?.tooltip ??
translate(
'auto.components.sidebar.WorktreeList.bb85cd86ba',
'Create workspace for {{value0}}',
{ value0: row.label }
)}
</TooltipContent>
</Tooltip>
) : null}
@@ -3240,9 +3213,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
forceActiveSurface = false
) => {
const lineageToggleGroupKey = itemRow.lineageGroupKey
// Why: child cards render inside the parent card body, so their
// first nested level starts flush with that inset.
// Why: child card rows own lineage depth, while WorktreeCard
// still owns the project/group inset inside each card surface.
const paddingDepth = nested ? Math.max(0, itemRow.depth - 1) : itemRow.depth
const nestedCardPaddingLeft = nested
? Math.max(0, itemRow.depth) * NESTED_LINEAGE_CARD_INDENT
: 0
const inheritedCardContentIndent = getWorktreeCardContentIndent({
isGrouped: groupBy !== 'none',
groupDepth: itemRow.groupDepth,
lineageDepth: 0
})
// Why: grouped rows inherit their project/group header depth,
// while the card surface still spans the full hit/background row.
const paddingLeft = getWorktreeCardContentIndent({
@@ -3250,6 +3231,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
groupDepth: itemRow.groupDepth,
lineageDepth: paddingDepth
})
const cardContentIndent = nested ? inheritedCardContentIndent : paddingLeft
const worktreeDragGroupKey = groupKeyByWorktreeId.get(itemRow.worktree.id)
const worktreeDragGroupIndex = groupIndexByWorktreeId.get(itemRow.worktree.id)
const revealHighlightTone =
@@ -3286,7 +3268,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
nested ? undefined : handleWorktreeRowPointerDown(event, itemRow.worktree.id)
}
style={{
paddingLeft: nested && paddingLeft > 0 ? `${paddingLeft}px` : undefined
paddingLeft:
nested && nestedCardPaddingLeft > 0 ? `${nestedCardPaddingLeft}px` : undefined
}}
>
<WorktreeCard
@@ -3302,8 +3285,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
revealHighlightTone={revealHighlightTone}
selectedWorktrees={selectedWorktrees}
nativeDragEnabled={false}
contentIndent={nested ? 0 : paddingLeft}
flushSurface={!nested}
contentIndent={cardContentIndent}
flushSurface
onImmediateActivate={onImmediateWorktreeActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
@@ -3330,199 +3313,36 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
}
const renderLineageChildCard = (child: WorktreeItemRow) => {
const isActive = activeWorktreeId === child.worktree.id
const isDeleting = deleteStateByWorktreeId[child.worktree.id]?.isDeleting ?? false
const revealHighlightTone =
agentSendTargetWorktreeId === child.worktree.id ? 'ai' : 'default'
const showStatus = cardProps.includes('status')
const showUnreadQuickAction = cardProps.includes('unread')
const unreadTooltip = child.worktree.isUnread ? 'Mark read' : 'Mark unread'
const stopQuickActionPointerPropagation = (
event: React.PointerEvent<HTMLButtonElement>
) => {
event.stopPropagation()
}
const handleToggleUnreadQuick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
onToggleWorktreeUnread(child.worktree)
}
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
if (isDeleting) {
return
const renderLineageDescendants = (
parent: WorktreeItemRow,
descendants: readonly WorktreeItemRow[]
): React.ReactNode | undefined => {
const childNodes: React.ReactNode[] = []
let cursor = 0
while (cursor < descendants.length) {
const child = descendants[cursor]
if (!child || child.depth !== parent.depth + 1) {
cursor++
continue
}
const selectionOnly = onSelectionGesture(event, child.worktree.id)
if (selectionOnly) {
return
}
onImmediateWorktreeActivate(child.worktree.id)
activateWorktreeFromSidebar(child.worktree.id)
if (child.repo?.connectionId) {
const sshStatus =
useAppStore.getState().sshConnectionStates.get(child.repo.connectionId)
?.status ?? 'disconnected'
if (sshStatus !== 'connected') {
setLineageReconnectWorktreeId(child.worktree.id)
}
let nextSiblingIndex = cursor + 1
while (
nextSiblingIndex < descendants.length &&
descendants[nextSiblingIndex]!.depth > child.depth
) {
nextSiblingIndex++
}
const childLineageChildren = renderLineageDescendants(
child,
descendants.slice(cursor + 1, nextSiblingIndex)
)
childNodes.push(renderWorktreeRow(child, true, childLineageChildren))
cursor = nextSiblingIndex
}
const lineageToggleGroupKey = child.lineageGroupKey
const childCardIndent = Math.max(0, child.depth) * LINEAGE_INDENT
const childContentIndent = Math.max(0, child.depth - 1) * LINEAGE_INDENT
return (
<div
key={child.worktree.id}
// Why: lineage child workspaces need their whole card surface
// nested, not only the text/status content inside the card.
style={childCardIndent > 0 ? { paddingLeft: `${childCardIndent}px` } : undefined}
>
<WorktreeContextMenu
worktree={child.worktree}
selectedWorktrees={selectedWorktrees}
onContextMenuSelect={(event) => onContextMenuSelect(event, child.worktree)}
>
<div
id={getWorktreeOptionId(child.worktree.id)}
role="option"
aria-selected={selectedWorktreeIds.has(child.worktree.id)}
aria-current={isActive ? 'page' : undefined}
aria-busy={isDeleting}
data-worktree-card-surface="true"
data-worktree-card-active={isActive ? 'true' : undefined}
className={cn(
'relative flex w-full cursor-pointer items-start gap-1.5 rounded-lg border border-transparent py-1.5 pr-2 transition-colors',
highlightedRevealWorktreeId === child.worktree.id && [
'scroll-to-current-workspace-reveal-highlight',
revealHighlightTone === 'ai' &&
'scroll-to-current-workspace-reveal-highlight--ai'
],
isActive
? 'border-black/[0.015] bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-border/40 dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
: 'worktree-sidebar-card-hover',
isDeleting && 'cursor-not-allowed opacity-50 grayscale'
)}
data-scroll-reveal-highlight={
highlightedRevealWorktreeId === child.worktree.id ? 'true' : undefined
}
onClick={handleClick}
onDoubleClick={(event) => event.stopPropagation()}
>
{isDeleting && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/50 backdrop-blur-[1px]">
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/50 bg-background px-3 py-1 text-[11px] font-medium text-foreground shadow-sm">
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
{translate("auto.components.sidebar.WorktreeList.5fc9d1891b", "Deleting…")}</div>
</div>
)}
<div
className="flex min-w-0 flex-1 items-start gap-1.5 pl-2"
style={
childContentIndent > 0
? { paddingLeft: `calc(0.5rem + ${childContentIndent}px)` }
: undefined
}
>
<span className="mt-[2px] flex w-4 shrink-0 justify-center pt-[2px]">
<WorktreeCardStatusSlot
worktreeId={child.worktree.id}
showStatus={showStatus}
showUnreadAction={showUnreadQuickAction}
isUnread={child.worktree.isUnread}
unreadTooltip={unreadTooltip}
onPointerDown={stopQuickActionPointerPropagation}
onToggleUnread={handleToggleUnreadQuick}
/>
</span>
<div className="min-w-0 flex-1">
<WorktreeTitleInlineRename
displayName={child.worktree.displayName}
className="text-[12px]"
onRename={(displayName) =>
updateWorktreeMeta(child.worktree.id, { displayName })
}
beginEditing={renamingWorktreeId === child.worktree.id}
onBeginEditingConsumed={() => setRenamingWorktreeId(null)}
/>
<div className="mt-1 flex min-w-0 items-center gap-1.5">
{child.repo && groupBy !== "repo" ? (
<span className="flex h-[16px] shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 text-[10px] font-semibold leading-none text-foreground dark:bg-accent/50 dark:border-border/60">
<RepoBadgeMark color={child.repo.badgeColor} />
<span className="max-w-[6rem] truncate lowercase">
{child.repo.displayName}
</span>
</span>
) : null}
<span className="truncate text-[10.5px] leading-none text-muted-foreground">
{branchDisplayName(child.worktree.branch)}
</span>
</div>
{child.worktree.linkedIssue || child.worktree.comment ? (
<div className="mt-1.5 truncate text-[10.5px] leading-tight text-muted-foreground">
{child.worktree.linkedIssue ? (
<span className="font-medium text-foreground/80">
#{child.worktree.linkedIssue}
</span>
) : null}
{child.worktree.linkedIssue && child.worktree.comment ? ' ' : null}
{child.worktree.comment}
</div>
) : null}
{showInlineAgentCards ? (
// Why: nested lineage children use this lightweight
// renderer instead of WorktreeCard, so their inline
// agent rows must be mounted here explicitly.
<WorktreeCardAgents
worktreeId={child.worktree.id}
className="mt-1 divide-y-0"
/>
) : null}
{child.lineageChildCount > 0 && lineageToggleGroupKey ? (
<div className="mt-1.5 flex min-w-0 justify-start">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="h-[18px] max-w-[8rem] gap-1 rounded-md border border-worktree-sidebar-border bg-worktree-sidebar px-1.5 text-[10px] font-medium leading-none text-muted-foreground shadow-none hover:bg-worktree-sidebar-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
aria-label={translate("auto.components.sidebar.WorktreeList.0c6ee14f23", "{{value0}} {{value1}} child {{value2}}", { value0: child.lineageCollapsed ? 'Show' : 'Hide', value1: child.lineageChildCount, value2: child.lineageChildCount === 1 ? 'workspace' : 'workspaces' })}
aria-expanded={!child.lineageCollapsed}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggleGroupWithScrollAnchor(lineageToggleGroupKey)
}}
>
<Workflow className="size-2.5" />
<span className="truncate">
{child.lineageChildCount}{' '}
{child.lineageChildCount === 1 ? translate("auto.components.sidebar.WorktreeList.0c6ee14f23", "child") : translate("auto.components.sidebar.WorktreeList.045a8aed48", "children")}
</span>
<ChevronDown
className={cn(
'size-2.5 transition-transform',
child.lineageCollapsed && '-rotate-90'
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{child.lineageCollapsed
? translate("auto.components.sidebar.WorktreeList.84a2238242", "Show child workspaces")
: translate("auto.components.sidebar.WorktreeList.ebc5c7dcef", "Hide child workspaces")}
</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
</div>
</div>
</WorktreeContextMenu>
</div>
)
return childNodes.length > 0 ? childNodes : undefined
}
if (row.type === 'lineage-group') {
@@ -3554,9 +3374,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
? renderWorktreeRow(
parent,
false,
children.length > 0
? children.map((child) => renderLineageChildCard(child))
: undefined,
renderLineageDescendants(parent, children),
childIsActive
)
: null}
@@ -4456,13 +4274,6 @@ const WorktreeList = React.memo(function WorktreeList({
[updateWorktreeMeta, worktreeMap, workspaceStatuses]
)
const toggleWorktreeUnread = useCallback(
(worktree: Worktree) => {
void updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread })
},
[updateWorktreeMeta]
)
const moveWorktreesToStatus = useCallback(
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
const updates = new Map<string, { workspaceStatus: WorkspaceStatus }>()
@@ -4711,14 +4522,17 @@ const WorktreeList = React.memo(function WorktreeList({
>
<div className="worktree-sidebar-scrollbar flex h-full flex-col overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek pt-px">
<div className="flex flex-col items-center gap-2 px-4 py-6 text-center text-[11px] text-muted-foreground">
<span>{translate("auto.components.sidebar.WorktreeList.b7acbf038b", "No workspaces found")}</span>
<span>
{translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')}
</span>
{hasFilters && (
<button
onClick={clearFilters}
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-[11px] px-2.5 py-1 rounded-md cursor-pointer hover:bg-accent transition-colors"
>
<CircleX className="size-3.5" />
{translate("auto.components.sidebar.WorktreeList.370c6a55dd", "Clear Filters")}</button>
{translate('auto.components.sidebar.WorktreeList.370c6a55dd', 'Clear Filters')}
</button>
)}
</div>
</div>
@@ -4731,12 +4545,20 @@ const WorktreeList = React.memo(function WorktreeList({
<ProjectGroupNameDialog
open={projectGroupNameDialog !== null}
title={
projectGroupNameDialog?.type === 'rename' ? translate("auto.components.sidebar.WorktreeList.f9dc6cc5d3", "Rename Project Group") : translate("auto.components.sidebar.WorktreeList.13757c053c", "New Project Group")
projectGroupNameDialog?.type === 'rename'
? translate('auto.components.sidebar.WorktreeList.f9dc6cc5d3', 'Rename Project Group')
: translate('auto.components.sidebar.WorktreeList.13757c053c', 'New Project Group')
}
description={
projectGroupNameDialog?.type === 'rename'
? translate("auto.components.sidebar.WorktreeList.bc1460beb3", "Update the group name shown in the sidebar.")
: translate("auto.components.sidebar.WorktreeList.d880ea0744", "Create a group and move this project into it.")
? translate(
'auto.components.sidebar.WorktreeList.bc1460beb3',
'Update the group name shown in the sidebar.'
)
: translate(
'auto.components.sidebar.WorktreeList.d880ea0744',
'Create a group and move this project into it.'
)
}
initialName={
projectGroupNameDialog?.type === 'rename'
@@ -4793,7 +4615,6 @@ const WorktreeList = React.memo(function WorktreeList({
selectedWorktrees={selectedWorktrees}
onSelectionGesture={updateSelectionForGesture}
onImmediateWorktreeActivate={handleImmediateWorktreeActivate}
onToggleWorktreeUnread={toggleWorktreeUnread}
onContextMenuSelect={selectForContextMenu}
repoMap={repoMap}
worktreeMap={worktreeMap}
@@ -4811,7 +4632,6 @@ const WorktreeList = React.memo(function WorktreeList({
onDropWorktreesOnWorkspaceBoard={dropWorktreesOnWorkspaceBoard}
shouldShowWorkspaceBoardDropIndicator={shouldShowWorkspaceBoardDropIndicator}
onReorderWorktrees={reorderWorktrees}
showInlineAgentCards={cardProps.includes('inline-agents')}
scrollOffsetRef={scrollOffsetRef}
scrollAnchorRef={scrollAnchorRef}
/>