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']!