Show destination worktree label on agent rows for live-worktree mismatch

This commit is contained in:
Jinjing
2026-07-25 17:50:55 -07:00
parent a6b5911b8b
commit 81e793b048
4 changed files with 189 additions and 3 deletions
@@ -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"')
})
})
@@ -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(<WorktreeCardAgents worktreeId="wt-1" />)
})
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()
})
})
@@ -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(<WorktreeCardAgents worktreeId="wt-1" />)
expect(markup).toContain('data-agent-live-worktree-mismatch')
expect(markup).toContain(' · in scratch-fix')
expect(markup).toContain('<span class="text-muted-foreground/90">Run tests</span>')
expect(markup).toContain('<span class="text-muted-foreground/65"> - Inspecting changes</span>')
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(<WorktreeCardAgents worktreeId="wt-1" />)
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(<WorktreeCardAgents worktreeId="wt-1" />)
expect(markup).toContain('1 elsewhere')
expect(markup).toContain('1 agent in another worktree')
})
})
@@ -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<AppState>)
@@ -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']!