From 1fb8fe5451aa0a9e0892584deeaa4297c7262a49 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:37:33 -0700 Subject: [PATCH] fix(sidebar): host-qualify discovery notice rows and collapse one checkout's twins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project checked out on several hosts emits one discovery-notice row per checkout, and those rows only named the project. A sidebar with paired remote hosts therefore showed several identical "N hidden worktrees" buttons under one project header, with no way to tell which machine each belonged to — or that one of them was another machine's worktree inbox entirely. Two causes, both fixed here: - Notice rows carried no host context, unlike worktree rows, which have been host-labelled since STA-4343. Both notice rows now take a host label, applied per project (not per rendered section, since a card can land in the pinned fallback) and only when that project spans hosts. The label also lands in the review, expand, and dismiss accessible names, so the actions that write to a specific host's repo record say which host that is. - One machine registered as a direct SSH target *and* paired as a runtime environment gives a single on-disk checkout two repo records with independent hidden-worktree state, so it emitted two rows for one directory. Repos now resolve to a (hostname, path) checkout key, and twins collapse to the record this client persists itself — its visibility state is the user's own and survives the paired runtime going away. The key is deliberately conservative: an unresolved hostname, or a tunnelled environment answering on loopback, yields no key and never collapses anything. Renderer-only; no wire or persistence change. --- .../ImportedWorktreesVisibilityLine.test.tsx | 20 +++ .../ImportedWorktreesVisibilityLine.tsx | 19 ++- .../NewExternalWorktreesInboxLine.test.tsx | 34 +++++ .../sidebar/NewExternalWorktreesInboxLine.tsx | 24 ++- .../sidebar/host-checkout-identity.test.ts | 138 ++++++++++++++++++ .../sidebar/host-checkout-identity.ts | Bin 0 -> 4455 bytes ...ree-list-groups-notice-host-labels.test.ts | 123 ++++++++++++++++ .../worktree-list/grouping/build-rows.ts | 16 +- .../worktree-list/grouping/group-sections.ts | 16 +- .../worktree-list/grouping/host-labels.ts | 44 +++++- .../grouping/pinned-group-rows.ts | 21 ++- .../worktree-list/grouping/row-builders.ts | 12 +- .../worktree-list/grouping/row-types.ts | 4 + .../listing/use-external-worktree-cards.ts | 56 ++++++- .../worktree-list/rows/notice-rows.tsx | 2 + .../src/store/slices/ssh-target-cleanup.ts | 20 ++- src/renderer/src/store/slices/ssh.ts | 22 ++- 17 files changed, 545 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/components/sidebar/host-checkout-identity.test.ts create mode 100644 src/renderer/src/components/sidebar/host-checkout-identity.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx index 527c7496a41..0dc3f7c3ce2 100644 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.test.tsx @@ -73,6 +73,26 @@ describe('ImportedWorktreesVisibilityLine', () => { expect(markup).not.toContain('/worktrees/demo-project') }) + it('names the host when the project is checked out on more than one', () => { + const markup = renderLine({ hostContextLabel: 'openclaw' }) + + expect(markup).toContain('openclaw') + expect(markup).toContain('Expand hidden worktrees for orca on openclaw') + expect(markup).toContain( + 'Keep 4 discovered worktrees hidden for orca on openclaw; recover from the project menu' + ) + }) + + it('folds the host into pinned fallback copy, which already names the repo', () => { + const markup = renderLine({ + hostContextLabel: 'openclaw', + placement: 'pinned-fallback', + onKeepHidden: undefined + }) + + expect(markup).toContain('Hiding 4 discovered worktrees in orca on openclaw') + }) + it('scopes pinned fallback copy to the repo name without a dismiss action', () => { const markup = renderLine({ placement: 'pinned-fallback', onKeepHidden: undefined }) diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx index 3c82858579e..60dcff4883d 100644 --- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx +++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx @@ -19,6 +19,9 @@ export type ImportedWorktreeVisibilityPreview = { type ImportedWorktreesVisibilityLineProps = { repoDisplayName: string + /** Host this checkout lives on. Set only when the project is checked out on + * more than one host, where the line alone cannot identify the row. */ + hostContextLabel?: string hiddenWorktrees: readonly ImportedWorktreeVisibilityPreview[] placement: ImportedWorktreesVisibilityPlacement pending: boolean @@ -74,6 +77,7 @@ export function groupWorktreesByParentPath( export default function ImportedWorktreesVisibilityLine({ repoDisplayName, + hostContextLabel, hiddenWorktrees, placement, pending, @@ -89,7 +93,11 @@ export default function ImportedWorktreesVisibilityLine({ const worktreeGroups = groupWorktreesByParentPath(hiddenWorktrees) const visibleWorktreeGroups = worktreeGroups.slice(0, GROUP_LIMIT) const remainingGroupCount = Math.max(0, worktreeGroups.length - visibleWorktreeGroups.length) - const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoDisplayName}; recover from the project menu` + // Why: two hosts checking out one project render two identical lines. + const repoScopeLabel = hostContextLabel + ? `${repoDisplayName} on ${hostContextLabel}` + : repoDisplayName + const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoScopeLabel}; recover from the project menu` if (hiddenCount === 0) { return null @@ -97,7 +105,7 @@ export default function ImportedWorktreesVisibilityLine({ const lineText = placement === 'pinned-fallback' - ? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoDisplayName}` + ? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoScopeLabel}` : `Hiding ${hiddenCount} discovered ${worktreeNoun}` const toggleGroupExpanded = (path: string): void => { @@ -133,7 +141,7 @@ export default function ImportedWorktreesVisibilityLine({ aria-label={translate( 'auto.components.sidebar.ImportedWorktreesVisibilityLine.f54f2bec5d', '{{value0}} hidden worktrees for {{value1}}', - { value0: isExpanded ? 'Collapse' : 'Expand', value1: repoDisplayName } + { value0: isExpanded ? 'Collapse' : 'Expand', value1: repoScopeLabel } )} onClick={() => setIsExpanded((value) => !value)} className="shrink-0 rounded-[4px] text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground" @@ -144,6 +152,11 @@ export default function ImportedWorktreesVisibilityLine({ /> {lineText} + {hostContextLabel && placement !== 'pinned-fallback' ? ( + + {hostContextLabel} + + ) : null} {onKeepHidden ? ( diff --git a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx index 38554945f12..426f9adf02d 100644 --- a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx +++ b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.test.tsx @@ -17,6 +17,7 @@ vi.mock('@/components/ui/tooltip', () => ({ const roots: Root[] = [] type RenderOverrides = { + hostContextLabel?: string inboxCount?: number pending?: boolean error?: string | null @@ -34,6 +35,7 @@ async function renderLine(overrides: RenderOverrides = {}): Promise { ) }) + it('names the host so two checkouts of one project are distinguishable', async () => { + // Both rows read "N hidden worktrees"; only the host tells them apart. + const local = await renderLine({ hostContextLabel: 'Local Mac', inboxCount: 61 }) + const remote = await renderLine({ hostContextLabel: 'openclaw', inboxCount: 134 }) + + expect(local.textContent).toContain('Local Mac') + expect(getReviewButton(local)?.getAttribute('aria-label')).toBe( + 'Review 61 hidden worktrees in orca on Local Mac' + ) + expect(getReviewButton(remote)?.getAttribute('aria-label')).toBe( + 'Review 134 hidden worktrees in orca on openclaw' + ) + }) + + it('host-qualifies the suppress control, which writes to that host alone', async () => { + const container = await renderLine({ hostContextLabel: 'openclaw', onSuppress: vi.fn() }) + + expect( + container.querySelector( + 'button[aria-label="Hide external worktrees permanently for orca on openclaw"]' + ) + ).not.toBeNull() + }) + + it('stays unqualified when the project has a single checkout', async () => { + const container = await renderLine() + + expect(getReviewButton(container)?.getAttribute('aria-label')).toBe( + 'Review 24 hidden worktrees in orca' + ) + }) + it('keeps suppress as a hover-revealed control that does not trigger review', async () => { const onReview = vi.fn() const onSuppress = vi.fn() diff --git a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx index 657b3b254e0..78a1999f93d 100644 --- a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx +++ b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx @@ -8,6 +8,9 @@ import { translate } from '@/i18n/i18n' type NewExternalWorktreesInboxLineProps = { repoDisplayName: string + /** Host this checkout lives on. Set only when the project is checked out on + * more than one host, where the count alone cannot identify the row. */ + hostContextLabel?: string inboxCount: number pending: boolean error: string | null @@ -18,6 +21,7 @@ type NewExternalWorktreesInboxLineProps = { export default function NewExternalWorktreesInboxLine({ repoDisplayName, + hostContextLabel, inboxCount, pending, error, @@ -29,10 +33,19 @@ export default function NewExternalWorktreesInboxLine({ 'auto.components.sidebar.NewExternalWorktreesInboxLine.c3e8a1f4b2', "Don't show again" ) + // Why: the same project on two hosts renders two identical rows, so every + // accessible name has to name the host as well as the project. + const repoScopeLabel = hostContextLabel + ? translate( + 'auto.components.sidebar.NewExternalWorktreesInboxLine.6c07f3a91e', + '{{value0}} on {{value1}}', + { value0: repoDisplayName, value1: hostContextLabel } + ) + : repoDisplayName const suppressAriaLabel = translate( 'auto.components.sidebar.NewExternalWorktreesInboxLine.9f2d4c8b17', 'Hide external worktrees permanently for {{value0}}', - { value0: repoDisplayName } + { value0: repoScopeLabel } ) const isSingular = inboxCount === 1 const countLabel = isSingular @@ -48,12 +61,12 @@ export default function NewExternalWorktreesInboxLine({ ? translate( 'auto.components.sidebar.NewExternalWorktreesInboxLine.7f18c5b0d3', 'Review {{value0}} hidden worktree in {{value1}}', - { value0: inboxCount, value1: repoDisplayName } + { value0: inboxCount, value1: repoScopeLabel } ) : translate( 'auto.components.sidebar.NewExternalWorktreesInboxLine.4e2b7a9c05', 'Review {{value0}} hidden worktrees in {{value1}}', - { value0: inboxCount, value1: repoDisplayName } + { value0: inboxCount, value1: repoScopeLabel } ) if (inboxCount === 0) { @@ -83,6 +96,11 @@ export default function NewExternalWorktreesInboxLine({ {inboxCount} {countLabel} + {hostContextLabel ? ( + + {hostContextLabel} + + ) : null}