From 81e793b04816cb421f6ff68e0f89c6d11f14fd1b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:50:55 -0700 Subject: [PATCH] Show destination worktree label on agent rows for live-worktree mismatch --- .../dashboard/DashboardAgentRow.test.tsx | 27 +++++ .../WorktreeCardAgents.activation.test.tsx | 48 ++++++++ .../sidebar/WorktreeCardAgents.test.tsx | 106 +++++++++++++++++- .../src/store/slices/worktrees.test.ts | 11 +- 4 files changed, 189 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 93238b8fc56..027ddc9da2b 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -417,4 +417,31 @@ describe('DashboardAgentRow', () => { expect(classTokens(markup)).toContain('worktree-agent-lineage-parent-row') expect(markup).toContain('aria-label="Show 2 child agents"') }) + + it('notes the destination worktree beside the prompt without displacing the row', () => { + const markup = renderRow( + makeAgent({ + liveWorktreeMismatch: { + destinationWorktreeId: 'wt-scratch', + destinationLabel: 'scratch-fix' + } + }) + ) + + expect(markup).toContain('data-agent-live-worktree-mismatch') + expect(markup).toContain('in scratch-fix') + expect(markup).toContain('Fix hover scope') + expect(markup).toContain('title="Fix hover scope in scratch-fix"') + expect(markup).toContain('aria-label="Dismiss agent"') + expect(classTokensForTaggedElement(markup, 'data-agent-live-worktree-mismatch')).toContain( + 'text-muted-foreground/70' + ) + }) + + it('omits the destination note when the agent runs in its own worktree', () => { + const markup = renderRow(makeAgent()) + + expect(markup).not.toContain('data-agent-live-worktree-mismatch') + expect(markup).toContain('title="Fix hover scope"') + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx index 90ac7cdf38c..75b8246a7ab 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx @@ -413,4 +413,52 @@ describe('WorktreeCardAgents activation', () => { act(() => root.unmount()) host.remove() }) + + it('activates the owning pane even when the row reports another worktree', async () => { + mockAgentActivityDisplayMode = 'compact' + const tabId = 'mismatch-tab' + const paneKey = makePaneKey(tabId, LEAF_B) + mockAgents = [ + { + ...mockAgent({ + paneKey, + tabId, + agentType: 'claude', + prompt: 'Editing elsewhere', + worktreeId: 'wt-1' + }), + // Why: the note is display-only; it must not redirect activation. + liveWorktreeMismatch: { + destinationWorktreeId: 'wt-scratch', + destinationLabel: 'scratch-fix' + } + } as DashboardAgentRowData + ] + mockAgentStatusByPaneKey = { [paneKey]: { worktreeId: 'wt-1' } } + mockTabsByWorktree = { 'wt-1': [{ id: tabId }] } + const host = document.createElement('div') + document.body.append(host) + const root: Root = createRoot(host) + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + await act(async () => { + root.render() + }) + const row = host.querySelector('.compact-agent-row') + expect(row?.textContent).toContain('in scratch-fix') + + await act(async () => { + row?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(activationMocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') + expect(activationMocks.activateAndRevealWorktree).not.toHaveBeenCalledWith('wt-scratch') + expect(activationMocks.activateTabAndFocusPane).toHaveBeenCalledWith(tabId, LEAF_B, { + ackPaneKeyOnSuccess: paneKey, + flashFocusedPane: true, + scrollToBottomIfOutputSinceLastView: true + }) + act(() => root.unmount()) + host.remove() + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index d5591b9e9d5..3a017d97570 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -29,6 +29,7 @@ type MockAgentOptions = { isLastSibling: boolean childCount: number } + liveWorktreeMismatch?: { destinationWorktreeId: string; destinationLabel: string } } function mockAgent({ @@ -43,13 +44,15 @@ function mockAgent({ stateStartedAt = 1000, terminalHandle, orchestration, - lineage + lineage, + liveWorktreeMismatch }: MockAgentOptions = {}): unknown { return { paneKey, tab: { id: tabId }, agentType, rowSource, + liveWorktreeMismatch, state, startedAt, entry: { @@ -895,4 +898,105 @@ describe('WorktreeCardAgents', () => { expect(markup).not.toContain('Child A') expect(markup).not.toContain('compact-agent-row') }) + + it('notes the destination worktree on a compact row without dropping its details', async () => { + mockAgentActivityDisplayMode = 'compact' + mockAgents = [ + mockAgent({ + agentType: 'codex', + rowSource: 'live', + startedAt: 1000, + prompt: 'Run tests', + lastAssistantMessage: 'Inspecting changes', + liveWorktreeMismatch: { + destinationWorktreeId: 'wt-scratch', + destinationLabel: 'scratch-fix' + } + }) + ] + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + const markup = renderToStaticMarkup() + + expect(markup).toContain('data-agent-live-worktree-mismatch') + expect(markup).toContain(' · in scratch-fix') + expect(markup).toContain('Run tests') + expect(markup).toContain(' - Inspecting changes') + expect(markup).toContain('title="Run tests · in scratch-fix - Inspecting changes"') + }) + + it('summarizes hidden panes running elsewhere on the collapsed compact affordance', async () => { + mockAgentActivityDisplayMode = 'compact' + mockAgents = [ + mockAgent({ + agentType: 'codex', + rowSource: 'live', + startedAt: 1000, + prompt: 'One', + liveWorktreeMismatch: { + destinationWorktreeId: 'wt-scratch', + destinationLabel: 'scratch-fix' + } + }), + mockAgent({ + paneKey: 'tab-1:2', + agentType: 'claude', + rowSource: 'live', + startedAt: 1500, + stateStartedAt: 1500, + prompt: 'Two', + liveWorktreeMismatch: { + destinationWorktreeId: 'wt-other', + destinationLabel: 'other-fix' + } + }), + mockAgent({ + paneKey: 'tab-1:3', + agentType: 'gemini', + rowSource: 'live', + startedAt: 1700, + stateStartedAt: 1700, + prompt: 'Three' + }) + ] + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + const markup = renderToStaticMarkup() + + expect(markup).toContain('aria-expanded="false"') + expect(markup).toContain('data-agent-live-worktree-mismatch-summary') + expect(markup).toContain('2 elsewhere') + expect(markup).toContain('2 agents in another worktree') + expect(markup).not.toContain('3 agents in another worktree') + }) + + it('uses the singular hidden-elsewhere phrasing for a single mismatched pane', async () => { + mockAgentActivityDisplayMode = 'compact' + mockAgents = [ + mockAgent({ + agentType: 'codex', + rowSource: 'live', + startedAt: 1000, + prompt: 'One', + liveWorktreeMismatch: { + destinationWorktreeId: 'wt-scratch', + destinationLabel: 'scratch-fix' + } + }), + mockAgent({ + paneKey: 'tab-1:2', + agentType: 'claude', + rowSource: 'live', + startedAt: 1500, + stateStartedAt: 1500, + prompt: 'Two' + }) + ] + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + const markup = renderToStaticMarkup() + + expect(markup).toContain('1 elsewhere') + expect(markup).toContain('1 agent in another worktree') + }) }) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 54e848ba2ad..0a70c108a06 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -382,7 +382,14 @@ describe('fetchWorktrees', () => { mockApi.worktrees.listDetected.mockResolvedValueOnce(detected) store.setState({ worktreesByRepo: { repo1: [existing] }, - detectedWorktreesByRepo: { repo1: detected }, + // Why: a committed refresh always carries the per-host authority record, so an + // unchanged payload only stays unchanged when the seed carries it too. + detectedWorktreesByRepo: { + repo1: { + ...detected, + authorityByHostId: { local: { authoritative: true, source: 'git' } } + } + }, sortEpoch: 7 } as Partial) @@ -981,7 +988,7 @@ describe('fetchWorktrees', () => { [makeWorktree({ id: 'repo-authority::/repo', repoId: 'repo-authority', path: '/repo' })], { authoritative: true, source: 'git' } ) - mockApi.worktrees.listDetected.mockResolvedValue(detected) + mockApi.worktrees.listDetected.mockResolvedValueOnce(detected).mockResolvedValueOnce(detected) await store.getState().fetchDetectedWorktrees('repo-authority') const committed = store.getState().detectedWorktreesByRepo['repo-authority']!