From 398fc79c4f3e5836b87d5f0cc82db0e2cbc26f6c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:13:29 -0700 Subject: [PATCH 1/2] Show markdown front-matter by default in the rich editor and preview (#8623) Flip the default so the front-matter banner is visible unless the user explicitly hides it. The per-file visibility map now stores only hide overrides (false) instead of show overrides: read defaults resolve to true, the setter writes false / deletes on show, session persistence preserves the real value, and hydration keeps false entries while dropping legacy true entries back to the visible default. Co-authored-by: Orca --- .../src/components/editor/EditorPanel.tsx | 3 +- .../src/components/editor/MarkdownPreview.tsx | 7 ++-- .../src/lib/workspace-session.test.ts | 10 ++--- src/renderer/src/lib/workspace-session.ts | 8 ++-- src/renderer/src/store/slices/editor.test.ts | 42 +++++++++---------- src/renderer/src/store/slices/editor.ts | 24 ++++++----- .../src/store/slices/settings.test.ts | 4 +- .../slices/store-session-cascades.test.ts | 42 +++++++++++++++++-- 8 files changed, 90 insertions(+), 50 deletions(-) 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/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 f938dfecdb0..2a9a3de8e94 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', () => { From 469018ec78f15d525101a070b029c68ca75f3064 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:15:44 -0700 Subject: [PATCH 2/2] fix(sidebar): decouple agent-list expansion from the child-worktrees toggle (#8527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-list expansion (compact "N agents" summary + per-parent lineage folds) lived in local useState on WorktreeCardAgents, so the WorktreeCard remount triggered by the child-worktrees toggle (a virtual-row key flip between item and lineage-group) and by virtualizer scroll-recycle wiped it — folding one section reset the other. Lift it into a session-scoped, LRU-bounded per-worktree module cache (useWorktreeAgentExpansionState), mirroring pr-comments-list-selection. Adds unit + integration + e2e regression tests. Reviewed for regressions and performance. --- pr-evidence/1-before-both-collapsed.png | Bin 0 -> 12865 bytes pr-evidence/2-agents-expanded.png | Bin 0 -> 16861 bytes ...-children-toggle-agents-still-expanded.png | Bin 0 -> 13369 bytes ...ktreeCardAgents.expansion-remount.test.tsx | 223 +++++++ .../components/sidebar/WorktreeCardAgents.tsx | 45 +- ....lineage-agent-expansion-coupling.test.tsx | 581 ++++++++++++++++++ .../worktree-card-agents-expansion-state.ts | 130 ++++ .../worktree-lineage-agent-expansion.spec.ts | 112 ++++ 8 files changed, 1072 insertions(+), 19 deletions(-) create mode 100644 pr-evidence/1-before-both-collapsed.png create mode 100644 pr-evidence/2-agents-expanded.png create mode 100644 pr-evidence/3-after-children-toggle-agents-still-expanded.png create mode 100644 src/renderer/src/components/sidebar/WorktreeCardAgents.expansion-remount.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-card-agents-expansion-state.ts create mode 100644 tests/e2e/worktree-lineage-agent-expansion.spec.ts diff --git a/pr-evidence/1-before-both-collapsed.png b/pr-evidence/1-before-both-collapsed.png new file mode 100644 index 0000000000000000000000000000000000000000..e8b721a239b256ce0689814cb357099d1e4ca698 GIT binary patch literal 12865 zcmeI3XEdB~yY8hBqK!`U-n)n{7%hZo2|=_WMDLwoFnW#XqD3Upd+)stiQcb{LI(x74;e2>~oHd5$dG7yxU)S%tC*+liJoe)!k5N!iuoV@c>L@7pNKsJk zi=f{JuQ(bud!nE~P!yq3nr^9EX&CjdM=!cuzsH$<${Bvn%4qY#)Evs9!C1z!L&R0c z(qng98pk!2F(f>chUC`b#@&|)lp{NG=AK8S8H zko=VY{h(g8ol#<`?Yx9~0;eu<83#0-<~{t^k2gh?*3&K$;nbXWMvzOMuf#d5_7>>ZxlEN?_(9w=!qF{eYn=lh z6a7JEchy2E91crNx^^bZ*7v)pxz%&Jr)`@k(PK_~{Dy6QeN$WQ``olu+V{5HoY$xo zo+j#wCcR=G`fxM&3y!OR{Zjj6+54^{Q>`b{Tn3F-e~X$L_dCh$<+rD+G6XC}f~kGF zrD>q=nCir1I~*i$uN;Tc#q*StS`WHo=pWD?2$Ah8tT_&bL2?L|YL_~~iXxg-jq7{j zm~AJDX(4pnaRpSHb-Rta<>qSRh5C|`3b!}cHI@P%M;rKzFLH8nuFjUDB(IM&jvT-r zq0tR)vX{Two+vR%9Qpb(Wh7TAC8hTIWO}_n>9Ci*2+I9PdIj~d#UL6jUsQSpLF+vX zMf67u473!{(Qv!x()jGxI-KZ4y?RZ@jj#%^^QjMJt@lU+fDR0K~v+M7(C4&l#k*9JWQDjD~`RM$2 zOfAFX_|K{>X9qfiUvzz~+~L|l*!hWC02;QHQC|Y*XrAi&JG<+PW6R&)O+(7_n{F;F zoqS$@V$))~I`89d;tAC*c>6SgghTsPp(`x_M2Wj>SB!(pPdj=x?{&CcH7`eH zgz?}J4%A|CXn(njTd%s2I&bEFcG;f#)SRpZO5JzC?~y-)+v=w`k4d;F2(kJ-^O$n5ioqK| zDTBu=3{lBZRrvO;U3GiU;&#^U5K36FoO3!dtd3P4XxbZJb3CT2rUt>73ngN&H12pz zpnf2Bv61~g!`mfA(Av(}a_py0qnB`kTDBavUaBl1L#deKnzD3@u1(Eqs^cIAo&7c@ zcl{nxo_a9`Zx8#~O!J)L?a`>JfyXA}V_NcU0mkBlqs6x1C<%Q>&Z0Z!dYWoz#CEJTw^n#kGjOqpQNflfl0(+!*4*7~^W z!U>E~JE<{~^2sg1i9QtFtuM2luIx#rew=Bi!Y5I0)E2Zwhp67?9};t%EH(WVR~MS2 zoF?MD+!+BL3w^L0yWodkELrwTf#C3_vm|F|lDo>cb!pMVzNzQ-y3Tu#*E(A+omdvu zFd@8*azNeXW%zi!Dps7oR5+v4EU#)M()~NMMvZwD&cK95pyv*;+BwBnSHC#gG%*rz z33AOMffK#;nVS8imczRsl~N1Jmj-_Vi;OP^RQ^P}&il0EVHsT1n9Lr@>rK^9>~A^+ z>|Mn@PjW7mE)aP1OfQpvN#3_-`YJ~!g-*tkjm(yRQ}Fiqc-&yV-kpDwfJxcy`1hOL zhSRxcb8Z1Dv)-q(BpQ!gUS8w<0ljKEOBU0?I6HqEwJkR&UYX!ys1QaN3Adc>H`AGF zdltb8lbWSa_UIP(^|WVJ6Fjd`ds@iOPMdBc(4tSbCd-;`Z!T&0us*7ARWJB^U!F|z z9vp%$dUlv`tCuMqST*Mn)(_hIpY{*}ou}4Q0$i`6sKl!umzfPbrW2nA6<=f071^EA zDoiI=*a&*x-T@J8WIcu>Yg#MU^;Nk)C*ds52tI+dZ;($U=V-gJ(c*X6Fk-w8>xtsU z)z1bvx*k@iajG{(0#A-%OK!>TmU*I18$51%hFp@{jEpAK8^i>X%i;XRR-G})g4Ur! z!hgPH^Ux$no-G7`5{%Z$OLIwfuG-VzZMtnF3iBTIwfyxBudiy>W%|>bcOjy=%^UiT zufX^*S&Z_%qJJtc$iXm!>CR)|5pYDr`!bSnpjdeGjlMIH`^1}8lgvzEa11Pr*|feB z<~;I32#3b0N{}Jb9eP2@BtfH`OEX z!Qh6{4G#QKK5StVX)X`Z86WSJfh`)!K+dfky{IwFH#0nUgq4G}S3%Fk9pxQkxKuz+ z2vHe2mbWLe?qdzScOryq?SEFf8S=d$2wWIIXHrVwP+a=+`$q_@HC6i)=PPuhe#@c; zGszy+dcx6*WG%9A#Sz22N)q--IXA?7v6>T=4LOcVC$&;goxg18)#(zxc2HX$xaAQ& zW~^5gS63C!7gB5+rRFvsJM%syDHDDa#-1JPR{T8YjY2rrFgIK6a!P~g^j;jQlJXnc zhtlXFYnArPol;}Z;Xw}Q(fyCxcwf{qa*Y0^`ICKkI4HUsjVRED5ka#^u)5n-X*q_C zinWlg^U~4KYGs7Ee7qeuazS^`O5v!TKvlV5rT-92dpwlv4{}a>JyIe`ye4Q;G&sVI zHy?dHL4}(?9H0$5I*uXDp4O-woy^7Q%KVosjKa$a2CW-Iry|}(A&_`5V2b5s6^*Cp+%==M;Ts>DEfWT*viY7q9JJB0|N=ck(GQ>#8qz z>M(Mv-`kz9e(p*Ymt$JqrAM+FZ>^+z?qu?Y;-rsu+@SyBnR~CUI>n@dI8Iwyyjs7^ z6ia67l)b&Yu^Pi*eby{Q%A1Z|5zONzEBc{psAUANb%8XclcU(GJQO3yb+v>+CC0hL z7=2r!w)xjdERWlCiOD19zT!-Bj6gUJuW51~jiq5RP6&SQ*Reo$bLrlxmLLk|#qIo! zJPRRAtV!N2KYl45r=Mg_kv^5EGK$}_X!zo7t1|H=(h15w!x^i^B072LrV>SlpZ1Cn z-DBpAsQAU)@C@xxtdj@ zfJFU();jb2H3o(O1Ci-d7Z2C%DaNMwWtbLq2!iX`55%8=B>wfPc^^y%0=GoasC>F2 zDSK>N6$i5xl8k{wfgn~`5CPcP@=+3hf0pgk{w*{cNY1_f@WJJvn?6dwcJ2y18;glz zqa!RHVbpgve}3irDMVTkjkMV_^q+K++XCgA17>wm}#v?K8pK*j5itM*ON zJE8KXt5t47>0_U(eR&tSV>X8GVju}(pb#ilpntN|L%lCHC){tr{{SnyD&b_*xUuh| z3`PY{2^q)qGAljFml-$z)wAB3lPODU2fpu4pmS*4((rML())nYlK{g}s7(q+Wig1A zcU^xtxEvol0{oSF?hAd_S!aPq@Rq;hMNMnTmRyZz#vc&PKfRL6R`RC$=2vRRS*k;U zLaC^+Fy{j%N&0ZeR7Ox+?B!ES>vtny25SCzjZKilnIY!R_r~M=U=_@w&FQM5iu}(8 zKEf(Ng4I@&r4t^jy`S*XU;g-5LZ_e!i%X>Y2x?($?Q^TJ;S5koLQPR-r zwLX}ybG6No@P^b6n3KVt{4v=*dHr28!~~b_kk06uV<7`6+-M3Wyh5`6XBIW%?Wu}X z#ldwj{mC>4nG-VU2XXs4!bq5zP1UQ3*!}fb7?Kb7o@x>QTpu9)GSE-jaB}DAmJsx7 zgK3_>wca4d#(uovMqn8s<)(B)zI6+B=|k-C7K{|7U}g(Idmhet?lSI_Zc=cNM`nR? znXPkW-v6bcS|OgS;vY~HIKBRSo2V8j<-q&a#`Gz!eziuhgs!h;^r%(!Y(MAz$pYG@ z?1)L&hnE^QFoGaMXZy>+*yLvSU~3rfHCQNU5i`-ngbYwk_f`z%FAs6zT)xXiJkcag znq(Nd6X{NB@J5QpH20~e$*~(F&}hs+_-C4<(oJW?2G1DW5(t%-9M53gkYvR|cHWei z%b|X9zFGd!L5RRHl3jT2R28TB*=92&cjB97!P|-_0_M-<@5`S8b2D;C+b&HTZyN~3 z&5nsvh#leKxL*(E=VVT_8&43Iodk)V-MxFbK}z4`oZ!0I&jKUlhHmA&Z;p$67b{*x z<{D0}Fq^l338^Cu`=MR3G@L2J8u2Q0@saWsXkCkOj(9O$X>D7($y6)DL4%RfFX6*!^T?9v3v=Mv z3a;;2TOzLs=`o!^=wMXm#^mkL?G>(^Nn97fy^-0Ag#g@SN*E|UZ5}fizgcERqI*cT zkwO5?HZU(n2Ec)azpM|vmFh%LxG%3xY?cj4TmnU$_7+d8bBHpGYES7Wwgy711ldLH zJM$en1OtW>9-qS>FYVQ5Z~xJc;Os-K96DMPT+BRC=w!F?G7^b?VjQQ|i4 z)?3mNv{7RstMGbYve~q|UyM)ig;dK{M^kvr&WLndM4Gzcld^mWftdpxSAY=zgPWGdDoV$qC~_T(nQMAT$0%DC zX3qmh!JI{crEv#_MfmJKTPaqLKvvJQawJjnICAzejtVzb!)W9PE>nt1via?l1?zy+W~Qyk$D&D*OKsasxrHFi3&n6 z@y1SK2ptgj{pAe*GjNf*OK%_c3ioMfJx*24sAz6@UNrEhdMy4-frRd#hl56sh5S;Y zm1U(?lEydMvt+}}0;949CcyEY3QjKbBylI?Hw-7cRJ_5zMDKx8#KnT2sj6ju+yj=^ zKS{f4-Vdd_JxT;`JP&XRd?O)U8r);zrDnD@L19Vy{p~orlLyjMil@$XH*!)ej;(iP zPWGm`&@)!0hqFBFb&N2aCVOkTDr(A>!cKm3?Q^`fI~i*Va}=xnYtdw1t7mJ!#Wk#s zkY?&_m8YO9df%L{e@*@H`@0r6A^>RhZ0WC%j}TeHdeC*v=A=qPoPB&*i#^f`G`e)? zFppebByiMNUOd`AeiX|PFRn2K6S+APUt7Eq|Ai=w+G+(e=X^MXmF&)VCEMe&?xDzvz6K+u*9k(<3WqC{FO?Y*uN# zU~BYKlMrKj8ymFa{;f96gUMLeI!Bj6@}s`0vdSXi3Mk`EiQQ=Ise*E~E1gJtnbW3v z{Nd#L4BE^mPd`K-=sd)?f4MnT*In^byM%6!o}5`y=c3&&SM|U*^N)DIpVO|QuIB~s zO&hymZS%9gKLk6JYK?I!3Job@31!@~G;?C}9kKrO}mVi3pERQnWlqpK8;U=y(+k>(<9W`vU4kEZ5V@gVCZ{ zzbJwEM_JYo=ffO?202-CWnQwu|NM$0O-8EM0nw0aW2 z-E^hS?Y90vSz2$PIlu~k#MfJGIq3@1w{uO{?qeraK4d(Tm>5h#$~4*|r7j6Efu4m> zNTEf(iT0V--(8=XT1Ad%Qi9pZIcu`j#6w;+TB`f?G)rH!D=l^(#GKmyQm;IT!6D?* z`lkJ<{4i04&1I*jiDoGCg>Q`TeFr$v`o_1Q%kY$ldj%-1MOziq*ObN%HD$s ziVkUL+)F=+Yq}{r%_C2xcMWe^Ym~P~Ti1xu{}_wmhb}SyG5+nlvBS(u&Gw7^-Tn{j zABfdKF7Fvn=fahu+_;Hvu+_7MW&a=?$`5ai6L%`mku6kN9{>l*Tsh%#5MB$@t5uv_ zjrl%ru(5#Bwu>2pM!u0Xp`UE`G19a>0KUPkL#6R1X88l7@P?OKO)~q5m64O*>MPkR zh8Qi3Qx(EsxPJgWwviqQ#gQ5SIiu0863uF|QiaK-qy{kRAx!yge}x?R&-vQxx0mlA zR2K6wT}2*It%WSR42kM5 z?xhwUYJ6hj8es=rR7i{P=J$8)ZV0=APH}5MRdrGsrCW*lDXt9j6D`hW26j~iInv%s zgvuCm{tk0z>-y?65ZaJUT@DMtKB4?9v!lPl?4Yf1)!)DDax%fP7$@#XNf957E!#ei z!N?HiW5t|u+UR*|BVL|tp!iO~#ye+YoaIEmdyjsC^oO{(;SICU*>llON=dz-H4`eI zzQKm+#``hUisN;264I^7CgE7zF-f2OSvZs4+im@>Jv&OBt?Q=NHcx<=QPWZ)AO(y* z_=hXM`sRe|Tw^48eyH%3>6*VsYW7dp2$P?)?BJ6%V+nOJNn+3i{n)IAzU!i^O-+qt z7y88#3^^xq$NjGWWxt=vR}43!ChiC)aY=4yw5)XL`(|9pD?86=fo@TNlzrzLz3W`I zdz%lNvS^mM89dXt+6XXKIZhUfjXOdC?3O9o30bkguN)?*hBAndoG_d)cRYz{C>Wtkwt9_6^RI@NpPY>E#gR_QbIR z+oS^k_jQJo9Xi8-hsiBD2jc#9q24)|$l?ldc=U1{Z;x&pyZPJ?V1ls4{JiwgbAdaY ze`lOvggElj_&p0V@}U`CkGRz;ttL;lr_02E_R)jxFLe;u)Y;6|f=HoD2qM-G#~@hi zPty3RoTS9vaFl4`li}hvSMLr07UL=~UF`a`0y`|7crTL$EPzD-`nP9=L-HayQ2~El zLfx!$iS3}H#3mL4nFZRq=jp5~XN-1>Okp35?+*|0vmo(mySaIY)kn>ND2JN zr}`c{Ga&_!Axhy-;EgPzGJhB7x}NPVUYREPA)5eY#8k`Gcbk$*v*`|H*gt;~D0L(| z#JtTdWw_yz9dZDeU~{)_^h2x<%&Bb>+b{VJUxpS#U_WgW!{BGWV;#lhrSa) zs1~>dFJ$QyuY#L4zf8YTJ$e$zA_VL4W(eEg>a=_Z1t-DS$6vKTl?;E2jjR^n6(wSFWp7QU@uTbm}RupH;oDTSTI zRe&U?T<7rLGY)|A(Q3jsUdR!=7OKD^7qprH=gynx1xzyC(f4vBQ|3`PQ|fg!FkjTZ zSG<^q>qEeS{?$-dAMODN99YG1=ev}n(fcy;$o&)e~J>9cV zK8h;h$@KcCHXC1(PqkJL55AB?)7c~QG9_}yGz zMp256jRfOTM-C2&9rnL#@L){Ey*9YLcmv|q$z$reuZwUhaTHHo^rz z#$czwe!u%Q#m4w>Filir@%akHBpZX5TL00HD+b<*D`3P8+#!E&A;{y+(XI7L8J6P$#ej-qO0evI%umN=Kh)4n{%D{>D zVzMeR1(|8GPCX1yR3*+Ct|@d2)w0oCCd zm9Oy#mjLQv8t@2LE+gFW6(3bTaLk-<%bRmwSv7bZ?+LG`I^ry{yo+B2BPbeMC-Ag* zXNmwKr$ zl~MOn;h`*VXS*uCMxSb16Yb+mGbCU$ojjB;Jrg6zORk`L$n&-qlz{z^#Q0y|bgQ^j zKF;A>Lnv?-XX05k{1I_RXZMIerell6FyX^pvw~08e8fMD-g-1&yJ8zDSd0r8Ib8BYS}%sX|vXmsn{uW6A< zQYZo^fpo>qLjplf7HdP{_pN#0A8R-M%I6KG3XdKvGiHr4tE7_ZzXk65E~NrAlBzU) zratR$;NNU|s649su*PFLeM;#UPR@X3k5U>ZKFo*cVUr7n&*6MGvDyZSg8)ej3W~F# zqu4t5hy0$E#dJb~rdSf>7C~6~ekLk5fB`+qzd82hp2=1qUJTC*LSgBh2HF(ziE;~q z4^eo{Aj*prJ5X>eNc~~kbe)hX4DYf-??Zop8Z@N45Q#oLa^@yVhK`=SOP@zFMS^yH zKV}>D`^^9*M>q=)MAfjzyq(f@rUgZx<}nJsdO~wjxKFw2il@T?UZz*Lre-wKlJNhq zm+?OrHCE=xOJ{gM2GdVo(TX=%EdGibp&X|a2#dN)=@xtHb!hlHcV_FXTd^!y4_8Kp zaSSx;-R$#OlopR1VolPmB^kQ8z3wT)SJY{da%*NpY7N4UYggc*O4-~e0Jn9QK;=#R zlhl=!StP)ncRCET$is+db7z2yjV9_cU4rxJzWRw~eOTtlrrFU3QdRs|GX$RzNF>MQ zh-W7oNcwJ6F+i&CaB32t>78~1D3vkwFI`-J^3%T)CQ%JXBT6MxzE{iCfCUx+G)Y6Z z!bmHAwb#bW>V0t(G9>4D-ipZ$)MpbI8FxWD$kJ6RF}bP@f&YGhhE-f~gk9^heet>J ziV@M@&I6PfV21Mm1%o&GDF1_IUv`LyI{$%}>`Yhf{yWSDA9f9Jomw|I^IBj*;&Qji z_v&mPwC>&UqIuT5RDAvWmmkJ{l078@RQeIM*WMHe*Upy09%`O~OCWlqCv{Bau|d0a z1CE{;=pQ-UfI z=_1a5z)%6#*roO5Ug$gwtg+m+XL+fjYQQGCpDG*Wf!O?~X8s7kDA79rkPBx3VNl7o z?+6-r&aVR9ahKo>22-H*fQ+yn$-$_b0Hgix6r6<-bG|qyV$(EqbQnw*Pw8J2n@D14 z!uAEnj(`N2tMpmdw&B?C5!C{gLjs1|9u;tEc`r>lZ64P7Zk2TLELn_xHL4R>1Z^CV z;fJuaYr}qKH}KBqd=+T*VJyerJ>j9%g^5C+@6`c^zb*+9)s}!qVDx3QF0b#%;3wjI z&;B25yc?HH1O4|USOc2GdxsMsL)C6JZs6;Ew=QtHt6HKedF`ZJfL>yiAX%9U>y-q3 zGYCxNP>`2H#qY*4jicdpDn;th({a53wJcqH@@F+eF0>6LCn*7WySQ0b#7iiEEOjhJD>xpFqjk7{g6<7%e4wSeo>*cNG z(rO+-3*ydD8Z{&^DDA2bKWbfrpjf*TXgeMos}3`^y#34R`5R*Bn&*PF`n|W%6oR;m z;g#tb?s&O?2p#1N2}-hJjkotcbW`P%3kIieqwI%b;HKU~q2TsscH}!$&%rMIXtLN} ztjr!MQ~7zFcRqxGnJy@jQglgI&}IhFck%Ro2#g*#__+rpDeJ+hw4hTrUjdegay7>L zt?xeyjX8tE(OyX>`d@wP0j;sy1#HYrZ&?CslJj&i+MW+s7CP1q_#rivsW1~8?0fL0 z6l0Q;v=rE%`*;6Ra+6Nxg!Rl(8X$*krL%;qHrM>S#hk|unT|>^6tl)1v?7I;5H*hL z4lODY=PST^pvG(})tsET@UWE*Y}XUOX3lcHyyxwxN8bA*;)jVz}bHWa;EQ0u>>@*qVoebohn2E8r-D zrJ@Xtu4C-0Zre=CclWLDW@EW3)amRRZfRi|LS|<`NmqOtvWcmynzq}J7IDG^N%&l8x!!a@sP0R$j6(2vOA%6hg7wUBX`sZ z__IMa^ds<6Avw)h8UA4luWGE=NCx_Qo2L5JpEe)X;NIyqf##JaFxM@bbdC>)BuD++ zPbS~zF9K`30k!|QMjQ8V$dEduWs3trhi3q-Z>NPjen)Dw(jf&WNm0rzFcri^{0BJ1 z9aa>a6nK<{f@z(dpji;`Z#c65_-e(k`Z2>k0iaBe>AnBZEqd7TzcM9G75+ zLU0`&d^M(VK-c7+1AIPypmluA#feYkhe{u&Y5V;U{hx#aabUKNL<1K3sQ?YVqK;25UEAI*y5&DC>|W z3}$#F1e+S0tZ$_x-uL!$UXS&lJ<8}Io}`Q)jNS%Xl{!L`MKC2VV#44+WiF5{x%~i6 zOF@{&9{y6VJ2x)FE^=)M5+C7|gk;+};#HvN?)ovLm>dcw^;$O8dOOB&S0K1)E!* ze0y5zu|t7;R@|3I+oktXQ5suo59k_J?nS|4x7}Y@u>LI$etq2j9kWJL!Nu8vWAs>D zGKS0i#r9~3U$;O_Ra86b>u%bE#~w1T|KozBpJrhE+_UIFmRIdy5Af~QKmpufgjo6^ zUc`jPpT#(W{Ms})ZXY^rIH?~4F}?`<+wyN&qO?oA^6=+b)GMMK5i=fQ@(4E^3L(g0 zyhn79Jl}w#l5~4CK5cCPVa|38`u5iL4;tk1hBV=GWxPe1dD{F)7Hx@??T-Us=g)f6 zqf?DgxxWbVwIh6-%mRBL@NH>Jqc`3S0WBoU(9rf7afo5xKHFoQ^P?(jL-Ios;bi$( z1ANqbWw+Ay?dxJk19a1TZza`ZScKqjACFM5Dx4HrI!WQjvIsE<_b0@3kqG!RL$N$u zr2gY&|92Nf|A`X*3%jNN`j!9cHtxUf*uU=B|62r@&P;>}zBDj$_Z@-%&VKm6D)Rq} ziVS_21kBz8KgfBYbl_uDvQ^)EC@8nLdJhsrz!yQ#?w;-g6kh3ux{v%M$KVwdMOhVS Jp|qj@zX2jh+;#u} literal 0 HcmV?d00001 diff --git a/pr-evidence/2-agents-expanded.png b/pr-evidence/2-agents-expanded.png new file mode 100644 index 0000000000000000000000000000000000000000..9b3c71f4791f5f892c17c1db81e70715b12ba20e GIT binary patch literal 16861 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 bHXKaWqZg$w+TsX!1m>NzqSQ|bqk#VfXs!qo literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..413204d402f1003f4fa17fe78d3ab203f60cde6f GIT binary patch literal 13369 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 literal 0 HcmV?d00001 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/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') + }) +})