mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen
This commit is contained in:
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}>
|
||||
|
||||
+581
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user