diff --git a/pr-evidence/1-before-both-collapsed.png b/pr-evidence/1-before-both-collapsed.png new file mode 100644 index 00000000000..e8b721a239b Binary files /dev/null and b/pr-evidence/1-before-both-collapsed.png differ diff --git a/pr-evidence/2-agents-expanded.png b/pr-evidence/2-agents-expanded.png new file mode 100644 index 00000000000..9b3c71f4791 Binary files /dev/null and b/pr-evidence/2-agents-expanded.png differ diff --git a/pr-evidence/3-after-children-toggle-agents-still-expanded.png b/pr-evidence/3-after-children-toggle-agents-still-expanded.png new file mode 100644 index 00000000000..413204d402f Binary files /dev/null and b/pr-evidence/3-after-children-toggle-agents-still-expanded.png differ diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 21b116ea246..43a9276b15e 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -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 diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 38d146c1312..f47b3610f3c 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -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(null) const [reviewNotesCopied, setReviewNotesCopied] = useState(false) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx new file mode 100644 index 00000000000..cc3a83d0a70 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx @@ -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 { + 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() + }) + 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('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 ( + + ) + } + + const host = document.createElement('div') + document.body.append(host) + let root = createRoot(host) + await act(async () => { + root.render() + }) + 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() + }) + 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) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 2a924211874..9e8faa70acd 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -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>( - () => 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() }} /> diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx new file mode 100644 index 00000000000..6190a6acd5a --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -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-` 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, + activateWorktreeFromSidebar: vi.fn(), + openModal: vi.fn() +})) + +type WorktreeListComponent = React.ComponentType<{ + scrollOffsetRef: React.RefObject + scrollAnchorRef: React.RefObject +}> + +let WorktreeList: WorktreeListComponent + +vi.mock('@/store', () => { + const useAppStore = ((selector: (state: Record) => unknown) => + selector(mockStore.state)) as (( + selector: (state: Record) => unknown + ) => unknown) & { + getState: () => Record + } + 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 }) => ( +
{children}
+ ), + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + 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 +}): 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 = { + [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(), + 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) + 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( + + ) + }) + 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 { + await act(async () => { + root.render( + + ) + }) +} + +function findButtonByAriaLabel(container: HTMLElement, pattern: RegExp): HTMLButtonElement | null { + return ( + [...container.querySelectorAll('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('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 { + 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).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') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts b/src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts new file mode 100644 index 00000000000..b357cb21956 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts @@ -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 + compactRootListExpanded: boolean +} + +const EMPTY_COLLAPSED_PARENTS: ReadonlySet = 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() + +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 + 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) +} diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index 2e24a297132..ee2884ac400 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -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', () => { diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 21a11eb0eec..f0acc22e620 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -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 { diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 0859e675615..ac54883ebe3 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 8e9e86c0d93..7e825ec6f77 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -1405,11 +1405,11 @@ export const createEditorSlice: StateCreator = (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 = (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 = (s const nextActiveTabType = nextActiveFileId || activeTabType !== 'editor' ? activeTabType : 'terminal' const openFileIds = new Set(openFiles.map((file) => file.id)) - const visibleFrontmatterEntries = new Map() + // 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() 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, diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index dd113fa668a..ad4c4983b58 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -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 }) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 71d5778cf3f..69296c5cb20 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -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', () => { diff --git a/tests/e2e/worktree-lineage-agent-expansion.spec.ts b/tests/e2e/worktree-lineage-agent-expansion.spec.ts new file mode 100644 index 00000000000..6662d7eafe0 --- /dev/null +++ b/tests/e2e/worktree-lineage-agent-expansion.spec.ts @@ -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 { + 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 { + 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') + }) +})