Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen

This commit is contained in:
Brennan Benson
2026-07-13 16:33:57 -07:00
16 changed files with 1162 additions and 69 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -346,8 +346,9 @@ function EditorPanelInner({
activeMarkdownContent &&
extractFrontMatter(activeMarkdownContent)
)
// Why: front-matter shows by default; the map only carries per-file hide overrides.
const isMarkdownFrontmatterVisible =
markdownFrontmatterVisible[markdownDocumentStateFileId] ?? false
markdownFrontmatterVisible[markdownDocumentStateFileId] ?? true
const isMarkdownTableOfContentsVisible =
markdownTableOfContentsVisible[markdownDocumentStateFileId] ?? false
@@ -619,12 +619,11 @@ export default function MarkdownPreview({
.replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '')
.trim()
}, [frontMatter])
// Why: front matter is hidden by default (#4468) and controlled from the
// markdown preview actions menu, keeping metadata out of the reading surface
// unless the user explicitly asks for it.
// Why: front matter shows by default and is toggled off from the markdown
// preview actions menu; the store map only carries per-file hide overrides.
const toggleableSourceFileId: string | null = sourceFileId ?? null
const frontmatterVisible = toggleableSourceFileId
? (frontmatterVisibleByFile[toggleableSourceFileId] ?? false)
? (frontmatterVisibleByFile[toggleableSourceFileId] ?? true)
: true
const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState<string | null>(null)
const [reviewNotesCopied, setReviewNotesCopied] = useState(false)
@@ -0,0 +1,223 @@
// @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 {
clearWorktreeAgentExpansionStateForTests,
getWorktreeAgentExpansionCountForTests,
MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS,
seedWorktreeAgentExpansionStateForTests,
useWorktreeAgentExpansionState
} from './worktree-card-agents-expansion-state'
let mockAgents: unknown[] = []
function mockAgent(paneKey: string, prompt: string): unknown {
return {
paneKey,
tab: { id: paneKey.split(':')[0] },
agentType: 'codex',
rowSource: undefined,
state: 'done',
startedAt: 1000,
entry: {
prompt,
lastAssistantMessage: undefined,
state: 'done',
stateStartedAt: 1000,
stateHistory: [],
orchestration: undefined
},
lineage: undefined
}
}
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
agentActivityDisplayMode: 'compact',
acknowledgedAgentsByPaneKey: {},
cacheTimerByKey: {},
dropAgentStatus: vi.fn(),
dismissRetainedAgent: vi.fn(),
agentSendPopoverTargetMode: null,
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
sendPromptToSidebarAgentTarget: vi.fn(),
settings: { promptCacheTimerEnabled: false, promptCacheTtlMs: 60_000 }
})
}))
vi.mock('./useWorktreeAgentRows', () => ({
useWorktreeAgentRows: vi.fn(() => mockAgents)
}))
vi.mock('@/components/dashboard/useNow', () => ({
useNow: vi.fn(() => 2000)
}))
vi.mock('./CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownForPane: () => null,
usePromptCacheCountdownStartedAt: () => null
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/lib/activate-tab-and-focus-pane', () => ({
activateTabAndFocusPane: vi.fn()
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
const mountedRoots: { root: Root; host: HTMLElement }[] = []
async function mountAgents(worktreeId: string): Promise<HTMLElement> {
const host = document.createElement('div')
document.body.append(host)
const root = createRoot(host)
mountedRoots.push({ root, host })
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
await act(async () => {
root.render(<WorktreeCardAgents worktreeId={worktreeId} />)
})
return host
}
function summaryButton(host: HTMLElement): HTMLButtonElement {
// The compact multi-agent summary is the only control carrying aria-expanded
// when the agents are flat (no per-agent child disclosure).
const button = host.querySelector<HTMLButtonElement>('button[aria-expanded]')
if (!button) {
throw new Error('compact agent summary button not found')
}
return button
}
describe('WorktreeCardAgents inline-list expansion durability', () => {
beforeEach(() => {
clearWorktreeAgentExpansionStateForTests()
mockAgents = [mockAgent('tab-1:1', 'One'), mockAgent('tab-2:2', 'Two')]
})
afterEach(async () => {
await act(async () => {
for (const { root, host } of mountedRoots.splice(0)) {
root.unmount()
host.remove()
}
})
document.body.innerHTML = ''
clearWorktreeAgentExpansionStateForTests()
})
it('keeps the compact agent summary expanded across a card remount', async () => {
const host = await mountAgents('wt-remount')
expect(summaryButton(host).getAttribute('aria-expanded')).toBe('false')
await act(async () => {
summaryButton(host).dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(summaryButton(host).getAttribute('aria-expanded')).toBe('true')
// Simulate the WorktreeCard remount that a virtualizer recycle or a sibling
// child-worktrees toggle triggers: fully unmount, then mount a fresh tree
// for the same worktree. Before the fix this reset the summary to collapsed.
await act(async () => {
const first = mountedRoots.shift()!
first.root.unmount()
first.host.remove()
})
const remounted = await mountAgents('wt-remount')
expect(summaryButton(remounted).getAttribute('aria-expanded')).toBe('true')
})
it('does not leak expansion between different worktrees', async () => {
const first = await mountAgents('wt-a')
await act(async () => {
summaryButton(first).dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(summaryButton(first).getAttribute('aria-expanded')).toBe('true')
const second = await mountAgents('wt-b')
expect(summaryButton(second).getAttribute('aria-expanded')).toBe('false')
})
})
describe('worktree-card-agents-expansion-state module cache', () => {
beforeEach(() => {
clearWorktreeAgentExpansionStateForTests()
})
afterEach(() => {
clearWorktreeAgentExpansionStateForTests()
})
it('persists a collapsed lineage parent across a hook remount and toggles independently', async () => {
function Probe({ worktreeId }: { worktreeId: string }) {
const { collapsedLineageParents, toggleLineageParent } =
useWorktreeAgentExpansionState(worktreeId)
return (
<button
type="button"
data-collapsed={collapsedLineageParents.has('pane-x') ? 'true' : 'false'}
onClick={() => toggleLineageParent('pane-x')}
>
probe
</button>
)
}
const host = document.createElement('div')
document.body.append(host)
let root = createRoot(host)
await act(async () => {
root.render(<Probe worktreeId="wt-probe" />)
})
const read = () => host.querySelector('button')!.getAttribute('data-collapsed')
expect(read()).toBe('false')
await act(async () => {
host.querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(read()).toBe('true')
// Remount the hook consumer: the collapsed parent must survive.
await act(async () => root.unmount())
root = createRoot(host)
await act(async () => {
root.render(<Probe worktreeId="wt-probe" />)
})
expect(read()).toBe('true')
await act(async () => root.unmount())
host.remove()
})
it('drops default (empty) state and bounds the cache with LRU eviction', () => {
seedWorktreeAgentExpansionStateForTests('wt-default', {
collapsedLineageParents: new Set(),
compactRootListExpanded: false
})
expect(getWorktreeAgentExpansionCountForTests()).toBe(0)
for (let i = 0; i < MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS + 25; i++) {
seedWorktreeAgentExpansionStateForTests(`wt-${i}`, {
collapsedLineageParents: new Set(),
compactRootListExpanded: true
})
}
expect(getWorktreeAgentExpansionCountForTests()).toBe(MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS)
})
})
@@ -1,4 +1,4 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
@@ -24,6 +24,7 @@ import {
import { buildAgentRowLineageTree } from '@/components/dashboard/agent-row-lineage-model'
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
import { revealElementInScrollContainer } from './worktree-sidebar-reveal'
import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state'
import { translate } from '@/i18n/i18n'
export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT =
@@ -231,13 +232,24 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
[agents]
)
const hasLineage = childrenByParentPaneKey.size > 0
const [collapsedLineageParents, setCollapsedLineageParents] = useState<ReadonlySet<string>>(
() => new Set()
)
const [compactRootListExpanded, setCompactRootListExpanded] = useState(false)
// Why: keep disclosure state out of raw local useState so the WorktreeCard
// remount that fires on virtualizer recycle or a sibling child-worktrees
// toggle no longer resets it (which read as one section expanding the other).
const {
collapsedLineageParents,
compactRootListExpanded,
toggleLineageParent: toggleLineageParentState,
toggleCompactRootList
} = useWorktreeAgentExpansionState(worktreeId)
// Why: reveal only on a genuine user collapse→expand within this mount.
// Seeding an already-expanded panel from the durable cache on a remount must
// not re-trigger the reveal scroll, or recycled cards would fight the scroll.
const previousCompactExpandedRef = useRef(compactRootListExpanded)
useLayoutEffect(() => {
if (compactRootListExpanded && agentActivityDisplayMode === 'compact') {
const wasExpanded = previousCompactExpandedRef.current
previousCompactExpandedRef.current = compactRootListExpanded
if (!wasExpanded && compactRootListExpanded && agentActivityDisplayMode === 'compact') {
dispatchSuppressScrollAdjustment()
// Why: defer the reveal scroll out of the expand commit. Running it inline
// forces a synchronous sidebar layout that blocks the animation's opening
@@ -250,18 +262,13 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
}
return undefined
}, [agentActivityDisplayMode, compactRootListExpanded])
const toggleLineageParent = useCallback((paneKey: string) => {
dispatchSuppressScrollAdjustment()
setCollapsedLineageParents((current) => {
const next = new Set(current)
if (next.has(paneKey)) {
next.delete(paneKey)
} else {
next.add(paneKey)
}
return next
})
}, [])
const toggleLineageParent = useCallback(
(paneKey: string) => {
dispatchSuppressScrollAdjustment()
toggleLineageParentState(paneKey)
},
[toggleLineageParentState]
)
const stopBubble = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
@@ -445,7 +452,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
expanded={compactRootListExpanded}
onToggle={() => {
dispatchSuppressScrollAdjustment()
setCompactRootListExpanded((expanded) => !expanded)
toggleCompactRootList()
}}
/>
<CompactAgentExpansion expanded={compactRootListExpanded}>
@@ -0,0 +1,581 @@
// @vitest-environment happy-dom
// Regression test for the child-worktrees <-> agent-list expansion coupling:
// in a worktree card that shows BOTH inline agent rows (with orchestration
// lineage) AND a "N children" child-worktrees chip, toggling the child-worktrees
// chip used to reset the agent list's expansion state (it remounts the card).
// It renders the REAL WorktreeCardAgents (not a mock) inside the REAL
// WorktreeList so the remount and the durable-expansion fix are exercised
// end-to-end. The two toggles must stay independent in both directions.
//
// NOTE: unlike the sibling lineage test, this file's useVirtualizer mock HONORS
// the real `getItemKey` (which returns getRenderRowKey(row)). Without that, the
// parent card's virtual-row key would be a static `row-<index>` and the
// item<->lineage-group remount would NOT reproduce (false negative).
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 {
AgentStatusEntry,
AgentStatusOrchestrationContext
} from '../../../../shared/agent-status-types'
import type {
Repo,
TerminalTab,
Worktree,
WorktreeCardProperty,
WorktreeLineage
} from '../../../../shared/types'
import { clearWorktreeAgentExpansionStateForTests } from './worktree-card-agents-expansion-state'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const mockStore = vi.hoisted(() => ({
state: {} as Record<string, unknown>,
activateWorktreeFromSidebar: vi.fn(),
openModal: 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 }
})
// Why: honor the real getItemKey so each virtual row's React key equals
// getRenderRowKey(row). This is what makes the parent card remount when it
// moves between a standalone 'item' row (key wt:...) and a 'lineage-group' row
// (key lineage-group:...) as the child-worktrees group collapses/expands.
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,
getItemKey
}: {
count: number
getItemKey?: (index: number) => string | number
}) => ({
elementsCache: new Map(),
getTotalSize: () => count * 96,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: getItemKey ? getItemKey(index) : `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,
usePromptCacheCountdownForPane: () => null
}))
// NOTE: intentionally NOT mocking ./WorktreeCardAgents — we render the real one.
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'
}))
const TAB_ID = 'tabP'
const PANE_ROOT = `${TAB_ID}:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa`
const PANE_CHILD = `${TAB_ID}:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb`
const PANE_ROOT_2 = `${TAB_ID}:cccccccc-cccc-4ccc-8ccc-cccccccccccc`
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/tmp/lineage-agent-coupling',
displayName: 'lineage-agent-coupling',
badgeColor: '#999999',
addedAt: 1
}
}
function makeWorktree(args: {
id: string
displayName: string
branch: string
sortOrder: number
instanceId: string
}): Worktree {
return {
id: args.id,
instanceId: args.instanceId,
repoId: 'repo-1',
path: `/tmp/lineage-agent-coupling/${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
}
}
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 makeAgentEntry(
paneKey: string,
prompt: string,
orchestration?: AgentStatusOrchestrationContext
): AgentStatusEntry {
const now = Date.now()
return {
state: 'working',
prompt,
updatedAt: now,
stateStartedAt: now,
agentType: 'claude',
paneKey,
worktreeId: 'parent',
stateHistory: [],
...(orchestration ? { orchestration } : {})
}
}
function makeParentTab(): TerminalTab {
return {
id: TAB_ID,
ptyId: null,
worktreeId: 'parent',
title: 'Parent Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
function setAgentLineageState(options: {
agentActivityDisplayMode: 'compact' | 'full'
secondRootAgent?: boolean
collapsedGroups?: Set<string>
}): 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
})
const agentStatusByPaneKey: Record<string, AgentStatusEntry> = {
[PANE_ROOT]: makeAgentEntry(PANE_ROOT, 'PARENT_AGENT_PROMPT'),
[PANE_CHILD]: makeAgentEntry(PANE_CHILD, 'CHILD_AGENT_PROMPT', {
taskId: 't1',
dispatchId: 'd1',
parentPaneKey: PANE_ROOT
})
}
if (options.secondRootAgent) {
agentStatusByPaneKey[PANE_ROOT_2] = makeAgentEntry(PANE_ROOT_2, 'SECOND_ROOT_PROMPT')
}
mockStore.state = {
// Reactive-ish toggle: the chip's onClick eventually calls
// toggleCollapsedGroup; mutate the live Set so the next manual re-render
// (store isn't reactive in this harness) reflects the user's toggle.
collapsedGroups: options.collapsedGroups ?? new Set<string>(),
toggleCollapsedGroup: vi.fn((key: string) => {
// Why: mirror the real store's IMMUTABLE update — a fresh Set reference is
// what makes WorktreeList's `rows` useMemo (keyed on the Set identity)
// recompute buildRenderableRows and flip the parent's render-row.
const set = new Set(mockStore.state.collapsedGroups as Set<string>)
if (set.has(key)) {
set.delete(key)
} else {
set.add(key)
}
mockStore.state.collapsedGroups = set
}),
// ── worktree list plumbing ──
activeModal: '',
activeView: 'terminal',
activeWorktreeId: null,
activeWorkspaceKey: null,
activeTabId: null,
activeTabType: null,
agentStatusEpoch: 0,
browserTabsByWorktree: {},
clearPendingRevealWorktreeId: vi.fn(),
deleteStateByWorktreeId: {},
detectedWorktreesByRepo: {},
fetchFolderWorkspacePathStatus: vi.fn(),
fetchHostedReviewForBranch: vi.fn(),
fetchIssue: vi.fn(),
fetchLinearIssue: vi.fn(),
filterRepoIds: [],
folderWorkspaces: [],
folderWorkspacePathStatuses: {},
getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request),
getFreshFolderWorkspacePathStatus: () => null,
gitConflictOperationByWorktree: {},
groupBy: 'none',
hideDefaultBranchWorkspace: false,
hostedReviewCache: {},
issueCache: {},
linearIssueCache: {},
linearStatus: null,
migrationUnsupportedByPtyId: {},
openModal: mockStore.openModal,
openSettingsPage: vi.fn(),
openSettingsTarget: null,
openTaskPage: vi.fn(),
pendingRevealWorktree: null,
prCache: {},
projectGroups: [],
ptyIdsByTabId: {},
recordFeatureInteraction: vi.fn(),
remoteBranchConflictByWorktreeId: {},
reorderRepos: vi.fn(),
reportVisibleGitHubPRRefreshCandidates: vi.fn(),
repos: [repo],
retainedAgentsByPaneKey: {},
revealWorktreeInSidebar: vi.fn(),
runtimePaneTitlesByTabId: {},
runtimeAgentOrchestrationByPaneKey: {},
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(),
terminalLayoutsByTabId: {},
updateRepo: vi.fn(),
updateWorktreeMeta: vi.fn(),
updateWorktreesMeta: vi.fn(),
workspaceHostScope: 'all',
workspacePortScan: null,
workspaceStatuses: [],
worktreeCardProperties: [
'status',
'pr',
'comment',
'inline-agents'
] satisfies WorktreeCardProperty[],
worktreeLineageById: { [child.id]: makeLineage(child, parent) },
worktreesByRepo: { [repo.id]: [parent, child] },
// ── agent-list specific state (real WorktreeCardAgents deps) ──
agentActivityDisplayMode: options.agentActivityDisplayMode,
agentSendPopoverTargetMode: null,
acknowledgedAgentsByPaneKey: {},
dropAgentStatus: vi.fn(),
dismissRetainedAgent: vi.fn(),
sendPromptToSidebarAgentTarget: vi.fn(),
tabsByWorktree: { parent: [makeParentTab()] },
agentStatusByPaneKey
}
}
const mountedRoots: Root[] = []
async function renderWorktreeList(): Promise<{ container: HTMLDivElement; root: Root }> {
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, root }
}
// Why: the mocked store isn't reactive. Re-render with FRESH ref props so
// React.memo(WorktreeList) doesn't bail out, letting a collapsedGroups mutation
// flow into buildRenderableRows (and thus flip the parent's virtual-row key).
async function rerender(root: Root): Promise<void> {
await act(async () => {
root.render(
<WorktreeList scrollOffsetRef={{ current: 0 }} scrollAnchorRef={{ current: null }} />
)
})
}
function findButtonByAriaLabel(container: HTMLElement, pattern: RegExp): HTMLButtonElement | null {
return (
[...container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')].find((button) =>
pattern.test(button.getAttribute('aria-label') ?? '')
) ?? null
)
}
function childWorktreeChip(container: HTMLElement): HTMLButtonElement | null {
return findButtonByAriaLabel(container, /child workspace/i)
}
function agentChildDisclosure(container: HTMLElement): HTMLButtonElement | null {
return findButtonByAriaLabel(container, /child agent/i)
}
function compactAgentSummary(container: HTMLElement): HTMLButtonElement | null {
return (
[...container.querySelectorAll<HTMLButtonElement>('button.compact-agent-summary-button')][0] ??
null
)
}
function childWorktreeCardPresent(container: HTMLElement): boolean {
return container.querySelector('[id="worktree-list-option-all%3Achild"]') !== null
}
function parentVirtualRowKey(container: HTMLElement): string | null {
return (
container
.querySelector('[data-worktree-id="parent"]')
?.closest('[data-worktree-virtual-row]')
?.getAttribute('data-worktree-virtual-row-key') ?? null
)
}
async function click(el: Element): Promise<void> {
await act(async () => {
el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
}
describe('WorktreeCard agent-list <-> child-worktrees expansion coupling', () => {
beforeAll(async () => {
WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent
}, 60_000)
beforeEach(() => {
vi.clearAllMocks()
// Expansion now persists in a module-level cache that survives remounts, so
// it must be reset between cases or one test's collapse leaks into the next.
clearWorktreeAgentExpansionStateForTests()
})
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots.splice(0)) {
root.unmount()
}
})
document.body.innerHTML = ''
clearWorktreeAgentExpansionStateForTests()
})
it('[full mode] both toggles render independently at mount', async () => {
setAgentLineageState({ agentActivityDisplayMode: 'full' })
const { container } = await renderWorktreeList()
// Sanity: real agent rows rendered (parent + its lineage child agent).
expect(container.textContent).toContain('PARENT_AGENT_PROMPT')
expect(container.textContent).toContain('CHILD_AGENT_PROMPT')
// Both controls exist and are independent DOM elements.
expect(childWorktreeChip(container)).not.toBeNull()
expect(agentChildDisclosure(container)).not.toBeNull()
// Defaults: child worktrees expanded (chip aria-expanded true) AND child
// agents expanded (disclosure aria-expanded true).
expect(childWorktreeChip(container)!.getAttribute('aria-expanded')).toBe('true')
expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('true')
expect(childWorktreeCardPresent(container)).toBe(true)
})
it('[full mode] toggling AGENTS does NOT change the child-worktrees chip (agents -> children uncoupled)', async () => {
setAgentLineageState({ agentActivityDisplayMode: 'full' })
const { container } = await renderWorktreeList()
expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('true')
expect(childWorktreeCardPresent(container)).toBe(true)
// Collapse the child AGENTS via the disclosure chevron.
await click(agentChildDisclosure(container)!)
// Agent children now collapsed (local state)...
expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('.worktree-agent-lineage-children')).toBeNull()
// ...but the child WORKTREES are untouched: chip still expanded, child card present.
expect(childWorktreeChip(container)!.getAttribute('aria-expanded')).toBe('true')
expect(childWorktreeCardPresent(container)).toBe(true)
// And collapsedGroups was never mutated by an agent toggle.
expect((mockStore.state.collapsedGroups as Set<string>).size).toBe(0)
})
it('[full mode] toggling CHILD WORKTREES still remounts the card but PRESERVES agent expansion (regression)', async () => {
setAgentLineageState({ agentActivityDisplayMode: 'full' })
const { container, root } = await renderWorktreeList()
// Parent starts inside a lineage-group render row (children expanded).
expect(parentVirtualRowKey(container)).toBe('lineage-group:all:lineage:parent')
// User collapses the child AGENTS.
await click(agentChildDisclosure(container)!)
expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false')
// User now clicks the CHILD-WORKTREES chip to collapse child worktrees.
await click(childWorktreeChip(container)!)
expect(mockStore.state.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent')
// Store isn't reactive; flush the collapsedGroups change into a re-render.
await rerender(root)
// The remount still happens: the parent moved to a standalone 'item' render
// row with a DIFFERENT React key, and the child card is gone.
expect(parentVirtualRowKey(container)).toBe('wt:all:parent')
expect(childWorktreeCardPresent(container)).toBe(false)
// FIXED: the card remounted, but the durable expansion cache means the
// child AGENTS the user collapsed stay collapsed — the child-worktrees
// toggle no longer bleeds into the agent list.
expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false')
expect(container.querySelector('.worktree-agent-lineage-children')).toBeNull()
})
it('[full mode] CONTROL: a re-render that does NOT change collapsedGroups preserves agent state (isolates the remount)', async () => {
setAgentLineageState({ agentActivityDisplayMode: 'full' })
const { container, root } = await renderWorktreeList()
await click(agentChildDisclosure(container)!)
expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false')
// Re-render WITHOUT touching collapsedGroups: the parent's virtual-row key
// stays 'lineage-group:all:lineage:parent', so there is no remount.
await rerender(root)
expect(parentVirtualRowKey(container)).toBe('lineage-group:all:lineage:parent')
// Agent collapse survives => proves it is the KEY change (remount), not the
// re-render itself, that resets the agent expansion.
expect(agentChildDisclosure(container)!.getAttribute('aria-expanded')).toBe('false')
})
it('[compact mode] toggling CHILD WORKTREES preserves the compact agent summary expansion (regression)', async () => {
setAgentLineageState({ agentActivityDisplayMode: 'compact', secondRootAgent: true })
const { container, root } = await renderWorktreeList()
// Two root agents => compact summary pill is shown, collapsed by default.
const summary = compactAgentSummary(container)
expect(summary).not.toBeNull()
expect(summary!.getAttribute('aria-expanded')).toBe('false')
// User expands the compact agent summary.
await click(summary!)
expect(compactAgentSummary(container)!.getAttribute('aria-expanded')).toBe('true')
// User toggles child worktrees.
await click(childWorktreeChip(container)!)
expect(mockStore.state.toggleCollapsedGroup).toHaveBeenCalledWith('lineage:parent')
await rerender(root)
// FIXED: the card remounts (child card gone), but the expanded "N agents"
// summary is restored from the durable cache instead of collapsing.
expect(childWorktreeCardPresent(container)).toBe(false)
expect(compactAgentSummary(container)!.getAttribute('aria-expanded')).toBe('true')
})
})
@@ -0,0 +1,130 @@
import { useCallback, useState } from 'react'
/**
* Expand/collapse state for a WorktreeCard's inline agent list:
* - `collapsedLineageParents`: agent-lineage parent paneKeys the user folded.
* - `compactRootListExpanded`: whether the "N agents" compact summary is open.
*/
export type WorktreeAgentExpansionState = {
collapsedLineageParents: ReadonlySet<string>
compactRootListExpanded: boolean
}
const EMPTY_COLLAPSED_PARENTS: ReadonlySet<string> = new Set()
const DEFAULT_EXPANSION_STATE: WorktreeAgentExpansionState = {
collapsedLineageParents: EMPTY_COLLAPSED_PARENTS,
compactRootListExpanded: false
}
// Why: the inline agent list's expand/collapse must outlive the WorktreeCard
// remount that fires (a) when the sidebar virtualizer recycles the row on
// scroll and (b) when the sibling child-worktrees section toggles — that toggle
// flips the card between an `item` and a `lineage-group` render row, changing
// its virtual-row key and forcing a fresh mount. Holding this in plain local
// useState reset it on every such remount, so folding one section visibly
// re-expanded the other. Renderer-only and LRU-bounded: it resets on reload,
// matching the ephemeral live-agent lineage it tracks, and never grows without
// bound in a long-lived renderer.
export const MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS = 512
const expansionByWorktreeId = new Map<string, WorktreeAgentExpansionState>()
function trimPersistedExpansions(): void {
while (expansionByWorktreeId.size > MAX_PERSISTED_WORKTREE_AGENT_EXPANSIONS) {
const oldest = expansionByWorktreeId.keys().next().value
if (oldest === undefined) {
break
}
expansionByWorktreeId.delete(oldest)
}
}
function readExpansionState(worktreeId: string): WorktreeAgentExpansionState {
return expansionByWorktreeId.get(worktreeId) ?? DEFAULT_EXPANSION_STATE
}
function persistExpansionState(worktreeId: string, state: WorktreeAgentExpansionState): void {
// Re-insert to refresh LRU order; drop entries that carry no non-default
// state so idle worktrees never occupy a slot.
expansionByWorktreeId.delete(worktreeId)
if (state.compactRootListExpanded || state.collapsedLineageParents.size > 0) {
expansionByWorktreeId.set(worktreeId, state)
trimPersistedExpansions()
}
}
export type WorktreeAgentExpansionControls = {
collapsedLineageParents: ReadonlySet<string>
compactRootListExpanded: boolean
/** Fold/unfold a single agent-lineage parent by its paneKey. */
toggleLineageParent: (paneKey: string) => void
/** Open/close the compact multi-agent summary panel. */
toggleCompactRootList: () => void
}
/**
* Remount-durable expand/collapse state for one worktree card's inline agent
* list. Reads seed from a module-level, session-scoped cache so a virtualizer
* recycle or a sibling child-worktrees toggle no longer wipes the user's
* disclosure choices. Independent per worktree id.
*/
export function useWorktreeAgentExpansionState(worktreeId: string): WorktreeAgentExpansionControls {
const [rendered, setRendered] = useState<{
worktreeId: string
state: WorktreeAgentExpansionState
}>(() => ({ worktreeId, state: readExpansionState(worktreeId) }))
// Why: a memoized body can be handed a new worktreeId without remounting;
// fall back to the cache so we never show a stale card's disclosure.
const current =
rendered.worktreeId === worktreeId ? rendered.state : readExpansionState(worktreeId)
const commit = useCallback(
(next: WorktreeAgentExpansionState) => {
persistExpansionState(worktreeId, next)
setRendered({ worktreeId, state: next })
},
[worktreeId]
)
const toggleLineageParent = useCallback(
(paneKey: string) => {
const base = readExpansionState(worktreeId)
const nextParents = new Set(base.collapsedLineageParents)
if (nextParents.has(paneKey)) {
nextParents.delete(paneKey)
} else {
nextParents.add(paneKey)
}
commit({ ...base, collapsedLineageParents: nextParents })
},
[commit, worktreeId]
)
const toggleCompactRootList = useCallback(() => {
const base = readExpansionState(worktreeId)
commit({ ...base, compactRootListExpanded: !base.compactRootListExpanded })
}, [commit, worktreeId])
return {
collapsedLineageParents: current.collapsedLineageParents,
compactRootListExpanded: current.compactRootListExpanded,
toggleLineageParent,
toggleCompactRootList
}
}
export function clearWorktreeAgentExpansionStateForTests(): void {
expansionByWorktreeId.clear()
}
export function getWorktreeAgentExpansionCountForTests(): number {
return expansionByWorktreeId.size
}
export function seedWorktreeAgentExpansionStateForTests(
worktreeId: string,
state: WorktreeAgentExpansionState
): void {
persistExpansionState(worktreeId, state)
}
@@ -176,18 +176,18 @@ describe('buildWorkspaceSessionPayload', () => {
expect(payload.browserTabsByWorktree?.['wt-1'][0].loading).toBe(false)
})
it('persists front-matter visibility only for restored editor files', () => {
it('persists front-matter hide overrides only for restored editor files', () => {
const payload = buildWorkspaceSessionPayload(
createSnapshot({
markdownFrontmatterVisible: {
'/tmp/demo.ts': true,
'/tmp/demo.diff': true,
'/tmp/closed.md': true
'/tmp/demo.ts': false,
'/tmp/demo.diff': false,
'/tmp/closed.md': false
}
})
)
expect(payload.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': true })
expect(payload.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': false })
})
it('does not persist empty split groups from transient simulator tab creation', () => {
+5 -3
View File
@@ -188,10 +188,12 @@ export function buildEditorSessionData(
WorkspaceVisibleTabType
>
const allEditFileIds = new Set(Object.values(editFileIdsByWorktree).flatMap((ids) => [...ids]))
// Why: preserve the actual value so per-file hide overrides survive restart;
// the map only ever carries `false` entries (visible is the default).
const persistedMarkdownFrontmatterVisible = Object.fromEntries(
Object.keys(markdownFrontmatterVisible ?? {})
.filter((fileId) => allEditFileIds.has(fileId))
.map((fileId) => [fileId, true])
Object.entries(markdownFrontmatterVisible ?? {}).filter(([fileId]) =>
allEditFileIds.has(fileId)
)
)
return {
+21 -21
View File
@@ -1482,7 +1482,7 @@ describe('createEditorSlice markdown view state', () => {
},
{ preview: true }
)
store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true)
store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', false)
store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true)
store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, {
@@ -1512,7 +1512,7 @@ describe('createEditorSlice markdown view state', () => {
worktreeId: 'wt-1',
language: 'markdown'
})
store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', true)
store.getState().setMarkdownFrontmatterVisible('/repo/docs/README.md', false)
store.getState().setMarkdownTableOfContentsVisible('/repo/docs/README.md', true)
store.getState().openDiff('wt-1', '/repo/docs/guide.md', 'docs/guide.md', 'markdown', false, {
@@ -1520,7 +1520,7 @@ describe('createEditorSlice markdown view state', () => {
})
expect(store.getState().markdownFrontmatterVisible).toEqual({
'/repo/docs/README.md': true
'/repo/docs/README.md': false
})
expect(store.getState().markdownTableOfContentsVisible).toEqual({
'/repo/docs/README.md': true
@@ -1573,28 +1573,28 @@ describe('createEditorSlice editor view mode', () => {
})
describe('createEditorSlice markdown frontmatter visibility (#4468)', () => {
it('stores visible=true as an explicit entry keyed by fileId', () => {
it('stores hidden=false as an explicit entry keyed by fileId', () => {
const store = createEditorStore()
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true)
expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true })
})
it('deletes the entry when visibility resets to hidden', () => {
const store = createEditorStore()
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true)
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false)
expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': false })
})
it('deletes the entry when visibility resets to visible', () => {
const store = createEditorStore()
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false)
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true)
expect(store.getState().markdownFrontmatterVisible).toEqual({})
})
it('is a no-op when hiding a file that was never shown', () => {
it('is a no-op when showing a file that was never hidden', () => {
const store = createEditorStore()
const before = store.getState().markdownFrontmatterVisible
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false)
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true)
expect(store.getState().markdownFrontmatterVisible).toBe(before)
})
@@ -1608,7 +1608,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => {
language: 'markdown',
mode: 'edit'
})
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true)
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false)
store.getState().closeFile('/repo/notes.md')
@@ -1630,11 +1630,11 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => {
worktreeId: 'wt-1',
language: 'markdown'
})
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true)
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false)
store.getState().closeFile('/repo/notes.md')
expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true })
expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': false })
store.getState().closeFile('markdown-preview::/repo/notes.md')
@@ -1662,7 +1662,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => {
},
{ sourceFileId: '/repo/notes.md' }
)
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true)
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false)
store.getState().openFile(
{
@@ -1675,7 +1675,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => {
{ preview: true }
)
expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': true })
expect(store.getState().markdownFrontmatterVisible).toEqual({ '/repo/notes.md': false })
})
it('drops the visibility flag when all files are closed', () => {
@@ -1687,7 +1687,7 @@ describe('createEditorSlice markdown frontmatter visibility (#4468)', () => {
language: 'markdown',
mode: 'edit'
})
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', true)
store.getState().setMarkdownFrontmatterVisible('/repo/notes.md', false)
store.getState().closeAllFiles()
+13 -11
View File
@@ -1405,11 +1405,11 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
markdownFrontmatterVisible: {},
setMarkdownFrontmatterVisible: (fileId, visible) =>
set((s) => {
// Why: default is hidden. Writing `false` explicitly when no entry exists
// would grow the record unnecessarily; delete instead so the shape stays
// minimal and hydration round-trips cleanly — same trade-off as
// setEditorViewMode above.
if (!visible) {
// Why: default is visible. Writing `true` explicitly when no entry exists
// would grow the record unnecessarily; delete instead so the map only
// carries hide overrides and hydration round-trips cleanly — same
// trade-off as setEditorViewMode above.
if (visible) {
if (!(fileId in s.markdownFrontmatterVisible)) {
return s
}
@@ -1417,7 +1417,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
delete next[fileId]
return { markdownFrontmatterVisible: next }
}
return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: true } }
return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: false } }
}),
// Markdown table of contents visibility
@@ -4496,24 +4496,26 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const nextActiveTabType =
nextActiveFileId || activeTabType !== 'editor' ? activeTabType : 'terminal'
const openFileIds = new Set(openFiles.map((file) => file.id))
const visibleFrontmatterEntries = new Map<string, boolean>()
// Why: visible is the default, so only restore per-file hide overrides
// (`false`); legacy `true` entries collapse back to the default.
const hiddenFrontmatterEntries = new Map<string, boolean>()
for (const [persistedFileId, visible] of Object.entries(
persistedMarkdownFrontmatterVisible
)) {
if (!visible) {
if (visible) {
continue
}
if (openFileIds.has(persistedFileId)) {
visibleFrontmatterEntries.set(persistedFileId, true)
hiddenFrontmatterEntries.set(persistedFileId, false)
}
for (const migrations of Object.values(editorFileIdMigrationsByWorktree)) {
const migratedFileId = migrations.get(persistedFileId)
if (migratedFileId && openFileIds.has(migratedFileId)) {
visibleFrontmatterEntries.set(migratedFileId, true)
hiddenFrontmatterEntries.set(migratedFileId, false)
}
}
}
const markdownFrontmatterVisible = Object.fromEntries(visibleFrontmatterEntries)
const markdownFrontmatterVisible = Object.fromEntries(hiddenFrontmatterEntries)
return {
openFiles,
@@ -242,7 +242,7 @@ describe('createSettingsSlice runtime switching', () => {
editorDrafts: { '/env-1/repo/stale.md': 'stale' },
markdownViewMode: { '/env-1/repo/stale.md': 'rich' },
editorViewMode: { '/env-1/repo/stale.md': 'changes' },
markdownFrontmatterVisible: { '/env-1/repo/stale.md': true },
markdownFrontmatterVisible: { '/env-1/repo/stale.md': false },
editorCursorLine: { '/env-1/repo/stale.md': 4 },
showDotfilesByWorktree: { 'repo-env-1::/env-1/repo': false },
gitIgnoredPathsByWorktree: { 'repo-env-1::/env-1/repo': ['dist/'] },
@@ -300,7 +300,7 @@ describe('createSettingsSlice runtime switching', () => {
expect(store.getState().markdownViewMode).toEqual({ '/env-1/repo/stale.md': 'rich' })
expect(store.getState().editorViewMode).toEqual({ '/env-1/repo/stale.md': 'changes' })
expect(store.getState().markdownFrontmatterVisible).toEqual({
'/env-1/repo/stale.md': true
'/env-1/repo/stale.md': false
})
expect(store.getState().editorCursorLine).toEqual({ '/env-1/repo/stale.md': 4 })
expect(store.getState().showDotfilesByWorktree).toEqual({ 'repo-env-1::/env-1/repo': false })
@@ -2079,7 +2079,7 @@ describe('hydrateEditorSession', () => {
},
activeFileIdByWorktree: { [wt]: '/path/wt1/src/index.ts' },
activeTabTypeByWorktree: { [wt]: 'editor' },
markdownFrontmatterVisible: { '/path/wt1/README.md': true }
markdownFrontmatterVisible: { '/path/wt1/README.md': false }
})
const s = store.getState()
@@ -2088,7 +2088,7 @@ describe('hydrateEditorSession', () => {
expect(s.openFiles[0].mode).toBe('edit')
expect(s.openFiles[0].isDirty).toBe(false)
expect(s.openFiles[1].isPreview).toBe(true)
expect(s.markdownFrontmatterVisible).toEqual({ '/path/wt1/README.md': true })
expect(s.markdownFrontmatterVisible).toEqual({ '/path/wt1/README.md': false })
expect(s.activeFileId).toBe('/path/wt1/src/index.ts')
expect(s.activeTabType).toBe('editor')
})
@@ -2167,10 +2167,46 @@ describe('hydrateEditorSession', () => {
[FLOATING_TERMINAL_WORKTREE_ID]: filePath
},
activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' },
markdownFrontmatterVisible: { [filePath]: false }
})
expect(store.getState().markdownFrontmatterVisible).toEqual({ [fileId]: false })
})
it('drops legacy visible=true front-matter entries so upgraded sessions fall back to the visible default', () => {
const store = createTestStore()
const filePath = '/orca/userData/floating-workspace/note.md'
const fileId = ownedEditorFileId(filePath, FLOATING_TERMINAL_WORKTREE_ID, null)
store.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID })
store.getState().hydrateEditorSession({
activeRepoId: null,
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
filePath,
relativePath: 'note.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: 'markdown',
runtimeEnvironmentId: null
}
]
},
activeFileIdByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: filePath
},
activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' },
// Pre-flip sessions stored `true` for the (then non-default) visible state.
markdownFrontmatterVisible: { [filePath]: true }
})
expect(store.getState().markdownFrontmatterVisible).toEqual({ [fileId]: true })
expect(store.getState().markdownFrontmatterVisible).toEqual({})
expect(fileId in store.getState().markdownFrontmatterVisible).toBe(false)
})
it('falls back to the floating workspace file id when duplicate paths are owner-qualified', () => {
@@ -0,0 +1,112 @@
import type { Page } from '@stablyai/playwright-test'
import { mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { seedLineageScenario } from './worktree-lineage-state'
import { worktreeRow } from './worktree-row-locators'
// Set ORCA_CAPTURE_EVIDENCE=1 to also write before/after screenshots to
// pr-evidence/. Off by default so CI just runs the behavioral assertions.
const CAPTURE_EVIDENCE = process.env.ORCA_CAPTURE_EVIDENCE === '1'
const SHOT_DIR = resolve(process.cwd(), 'pr-evidence')
async function captureSidebar(page: Page, name: string): Promise<void> {
if (!CAPTURE_EVIDENCE) {
return
}
mkdirSync(SHOT_DIR, { recursive: true })
await sidebar(page).screenshot({ path: resolve(SHOT_DIR, name) })
}
// Seed two independent root agents on the parent worktree so its card shows the
// compact "2 agents" summary pill alongside the "N child workspaces" chip.
async function seedTwoParentAgents(page: Page, worktreeId: string): Promise<void> {
await page.evaluate((worktreeId) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
if (!state.worktreeCardProperties.includes('inline-agents')) {
state.toggleWorktreeCardProperty('inline-agents')
}
while ((store.getState().tabsByWorktree[worktreeId] ?? []).length < 2) {
store.getState().createTab(worktreeId)
}
const tabs = (store.getState().tabsByWorktree[worktreeId] ?? []).slice(0, 2)
const now = Date.now()
const specs = [
{ state: 'working' as const, prompt: 'Refactor auth middleware', agentType: 'claude' },
{ state: 'done' as const, prompt: 'Write unit tests for parser', agentType: 'codex' }
]
tabs.forEach((tab, index) => {
const spec = specs[index]!
const leafId = crypto.randomUUID()
store
.getState()
.setAgentStatus(
`${tab.id}:${leafId}`,
{ state: spec.state, prompt: spec.prompt, agentType: spec.agentType },
spec.agentType,
{ updatedAt: now, stateStartedAt: now }
)
})
}, worktreeId)
}
function sidebar(page: Page) {
return page.locator('[data-worktree-sidebar]').first()
}
function compactSummary(page: Page, parentId: string) {
return worktreeRow(page, parentId).locator('button.compact-agent-summary-button').first()
}
function childWorkspacesChip(page: Page, parentId: string) {
return worktreeRow(page, parentId)
.getByRole('button', { name: /child workspace/i })
.first()
}
test.describe('Worktree lineage agent-list expansion independence', () => {
test.describe.configure({ mode: 'serial' })
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
})
test('toggling child worktrees does not collapse the expanded agent summary', async ({
orcaPage
}) => {
const { parentId, childId } = await seedLineageScenario(orcaPage)
const parentRow = worktreeRow(orcaPage, parentId)
const childRow = worktreeRow(orcaPage, childId)
await parentRow.click()
await expect(parentRow).toHaveAttribute('aria-current', 'page')
await seedTwoParentAgents(orcaPage, parentId)
// Both sections present: the "2 agents" summary and the child-workspaces chip.
await expect(compactSummary(orcaPage, parentId)).toBeVisible({ timeout: 10_000 })
await expect(childWorkspacesChip(orcaPage, parentId)).toBeVisible()
await expect(childRow).toBeVisible()
await expect(compactSummary(orcaPage, parentId)).toHaveAttribute('aria-expanded', 'false')
await captureSidebar(orcaPage, '1-before-both-collapsed.png')
// Expand the agent summary.
await compactSummary(orcaPage, parentId).click()
await expect(compactSummary(orcaPage, parentId)).toHaveAttribute('aria-expanded', 'true')
await captureSidebar(orcaPage, '2-agents-expanded.png')
// Collapse the child worktrees via the chip. This remounts the parent card.
await childWorkspacesChip(orcaPage, parentId).click()
await expect(childRow).toBeHidden()
// FIXED: the agent summary stays expanded despite the card remount.
await expect(compactSummary(orcaPage, parentId)).toHaveAttribute('aria-expanded', 'true')
await captureSidebar(orcaPage, '3-after-children-toggle-agents-still-expanded.png')
})
})