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}
= {}): Repo {
+ return {
+ id,
+ path: '/home/brennan/orca',
+ displayName: 'orca',
+ badgeColor: '#000',
+ addedAt: 1,
+ ...overrides
+ } as Repo
+}
+
+function environment(
+ id: string,
+ endpoint: string,
+ overrides: Partial = {}
+): PublicKnownRuntimeEnvironment {
+ return {
+ id,
+ name: id,
+ createdAt: 1,
+ updatedAt: 1,
+ lastUsedAt: null,
+ runtimeId: null,
+ endpoints: [{ id: `ws-${id}`, kind: 'websocket', label: 'WebSocket', endpoint }],
+ preferredEndpointId: `ws-${id}`,
+ ...overrides
+ } as PublicKnownRuntimeEnvironment
+}
+
+const SSH_REPO = repo('ssh-repo', { connectionId: 'ssh-target-1' })
+const ENVIRONMENT_REPO = repo('environment-repo', { executionHostId: 'runtime:env-1' })
+const SSH_HOSTS = new Map([['ssh-target-1', 'openclaw.example.ts.net']])
+const ENVIRONMENTS = [environment('env-1', 'ws://openclaw.example.ts.net:16770')]
+
+function duplicatesFor(args: {
+ repos: readonly Repo[]
+ sshTargetHostsById?: ReadonlyMap
+ runtimeEnvironments?: readonly PublicKnownRuntimeEnvironment[]
+ noticeRepoIds?: ReadonlySet
+}): Set {
+ return getDuplicateCheckoutNoticeRepoIds({
+ repos: args.repos,
+ noticeRepoIds: args.noticeRepoIds ?? new Set(args.repos.map((entry) => entry.id)),
+ checkoutKeyByRepoId: buildRepoCheckoutKeys({
+ repos: args.repos,
+ sshTargetHostsById: args.sshTargetHostsById ?? SSH_HOSTS,
+ runtimeEnvironments: args.runtimeEnvironments ?? ENVIRONMENTS
+ })
+ })
+}
+
+describe('host checkout identity', () => {
+ it('keys an SSH target and a paired environment on one machine to one checkout', () => {
+ const keys = buildRepoCheckoutKeys({
+ repos: [SSH_REPO, ENVIRONMENT_REPO],
+ sshTargetHostsById: SSH_HOSTS,
+ runtimeEnvironments: ENVIRONMENTS
+ })
+
+ expect(keys.get('ssh-repo')).toBe(keys.get('environment-repo'))
+ })
+
+ it('shadows the environment twin so one checkout emits one notice row', () => {
+ expect([...duplicatesFor({ repos: [SSH_REPO, ENVIRONMENT_REPO] })]).toEqual([
+ 'environment-repo'
+ ])
+ })
+
+ it('keeps the client-owned record whichever order the repos arrive in', () => {
+ expect([...duplicatesFor({ repos: [ENVIRONMENT_REPO, SSH_REPO] })]).toEqual([
+ 'environment-repo'
+ ])
+ })
+
+ it('keeps both rows when the same path lives on two different machines', () => {
+ const otherEnvironment = [environment('env-1', 'ws://windows-box.example.ts.net:6768')]
+
+ expect(
+ duplicatesFor({
+ repos: [SSH_REPO, ENVIRONMENT_REPO],
+ runtimeEnvironments: otherEnvironment
+ }).size
+ ).toBe(0)
+ })
+
+ it('keeps both rows when the paired environment answers on loopback', () => {
+ // A tunnelled environment reports 127.0.0.1, which would otherwise make
+ // every tunnelled host look like the same machine.
+ const tunnelled = [environment('env-1', 'ws://127.0.0.1:16770')]
+
+ expect(
+ buildRepoCheckoutKeys({
+ repos: [ENVIRONMENT_REPO],
+ sshTargetHostsById: SSH_HOSTS,
+ runtimeEnvironments: tunnelled
+ }).size
+ ).toBe(0)
+ expect(
+ duplicatesFor({ repos: [SSH_REPO, ENVIRONMENT_REPO], runtimeEnvironments: tunnelled }).size
+ ).toBe(0)
+ })
+
+ it('keeps both rows when the SSH target hostname has not hydrated', () => {
+ expect(
+ duplicatesFor({ repos: [SSH_REPO, ENVIRONMENT_REPO], sshTargetHostsById: new Map() }).size
+ ).toBe(0)
+ })
+
+ it('keeps two checkouts on one machine apart', () => {
+ const secondCheckout = repo('environment-repo', {
+ executionHostId: 'runtime:env-1',
+ path: '/home/brennan/orca-review'
+ })
+
+ expect(duplicatesFor({ repos: [SSH_REPO, secondCheckout] }).size).toBe(0)
+ })
+
+ it('never shadows a repo that has no notice row of its own', () => {
+ expect(
+ duplicatesFor({
+ repos: [SSH_REPO, ENVIRONMENT_REPO],
+ noticeRepoIds: new Set(['environment-repo'])
+ }).size
+ ).toBe(0)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/host-checkout-identity.ts b/src/renderer/src/components/sidebar/host-checkout-identity.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1ddb3384f4fded306fb9dd4c273be921fd43428b
GIT binary patch
literal 4455
zcmb7HZExE)5Z-71iYpM*c3{i<9Lx#YG(&@SO_8+NfV695iMEKyq##mpy(IsA&yk``
z$Emv&O{0pu_vfBF$z`Q$OZTL7y-Z8_kGO1T8n&=)!LYGRi<_hHRB4h{hn-9_lVwKoW6Sh?d-3!)6bvIFFt&GdvW#Y+xeRj
zjuygRib|h-7g=MaR&TYj=Q*Fit`Se(_x3m;f{Jkg9UJ?Um8mguzP=PoZ4sEPrCP8Z
zl`h4;uxclOaGbFVk&*|bL4=rC3JA>y@FEv;|3(i%Q@h?o{uQzX0#*p3gP5@Qs#8G>B7#`?>RD;~#1K^of$avRyIaCMdaY~fH
z&T=qppY?ma67^|nM0kaeO9V2Q{Qc$n;mg;9$znwPetW+={E{3@!h1QV!HbQ-p>uaC`;rmDk`PQz!`%YQmJ({OS9Wh=#_5D9QeBxB<@7L
z_S!aTI7Ak@rBdP!5aHp_A~vDV3SFklJBQktNKw8OdSZdn1W)&N^dJs!I-OEKxY(x?
zpZ%jK>%GUQl7My8f?YYI(2rh%Iqff9A#zU7AC06wL@;!X@
zVRbB2UHv+wA@?9H`oJx*DuiOhI7norKE
zck-FQXRpLx1kRzkgHj}o=H5Z+=6Fcx`VGT*+fglGJo=xj%*J$d;_bJT8NCO${G2^H
zCjk6*$aGknHL}bvx2KI609@wCbq^N_kC^BVT@HfR+|#=`+lxU+v~&6_S|1|Cd!Yx5
zj{skc?I%7TJ8yUL>oSTYGjBk8;EKr6CC_-1EO7(W(rrVI46maX;KWf}u&;~?{_%!y
z#To|LvvZ!StpStWuDt1>IcyZ1(={b4-IPn4Z{+vPWNNE}tSjI2EalJJ1g1d#;
za@l3XgV}3k{$XbWirb~|BVKQ<;?73mZl9cVBfsli=NS6;1>XpMyTn+j8Z2P=!`(>~
zXDKo0GmhJ3_U7O|#Q6Bj!98nwykTEiumyiC%~O<_{1px}UFYCg?3pc#S6CIWPGa@6
zMwIg-{=Ku#@m|Ul-xyK%v01E?BXjJSys_Pd^)c+5Vbxq51pO7rcM8zV_QBaa;^Q
zk~vlzq*@p(3^%&!%0t_iA&
zwSjoq&Ah}JYZq#T?Rv9ag|Wy$0A`ij_Svg+W_bL%5561k9D978n;XV99Cni$gEPlg
zns05*9if2^--KAg2K~VW+Uoi*zky1#SN?^wIVU
literal 0
HcmV?d00001
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts
new file mode 100644
index 00000000000..efe0b3a9983
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts
@@ -0,0 +1,123 @@
+/**
+ * A project checked out on several hosts emits one discovery-notice row per
+ * checkout, and those rows only ever named the project — so a sidebar with a
+ * paired remote host showed two identical "N hidden worktrees" buttons with no
+ * way to tell which machine either belonged to.
+ */
+import { describe, expect, it } from 'vitest'
+import { buildRows } from './worktree-list/grouping/build-rows'
+import {
+ repo,
+ worktree,
+ remoteRepo,
+ remoteWorktree,
+ project,
+ projectHostSetups
+} from './worktree-list-groups-test-fixtures'
+import type { DetectedWorktree } from '../../../../shared/worktree/types'
+import type { Row } from './worktree-list/grouping/row-types'
+
+const HOST_LABELS = new Map([
+ ['local', 'Local Mac'],
+ ['ssh:gpu-vm', 'openclaw']
+])
+
+function detected(path: string): DetectedWorktree {
+ return { path, visible: false } as DetectedWorktree
+}
+
+function buildNoticeRows(args: {
+ inboxRepoIds?: readonly string[]
+ importedRepoIds?: readonly string[]
+ worktrees?: Parameters[1]
+ repoMap?: Parameters[2]
+ projectGrouping?: Parameters[17]
+}): Row[] {
+ const rows = buildRows(
+ 'repo',
+ args.worktrees ?? [worktree, remoteWorktree],
+ args.repoMap ??
+ new Map([
+ [repo.id, repo],
+ [remoteRepo.id, remoteRepo]
+ ]),
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ undefined,
+ false,
+ undefined,
+ [],
+ new Set(),
+ new Map(
+ (args.importedRepoIds ?? []).map((repoId) => [
+ repoId,
+ {
+ repo: repoId === repo.id ? repo : remoteRepo,
+ hiddenWorktrees: [detected(`/hidden/${repoId}`)]
+ }
+ ])
+ ),
+ new Map(
+ (args.inboxRepoIds ?? []).map((repoId) => [
+ repoId,
+ {
+ repo: repoId === repo.id ? repo : remoteRepo,
+ inboxWorktrees: [detected(`/inbox/${repoId}`)]
+ }
+ ])
+ ),
+ [],
+ args.projectGrouping ?? { projects: [project], projectHostSetups },
+ [],
+ HOST_LABELS
+ )
+ return rows.filter(
+ (row) => row.type === 'new-external-worktrees-inbox' || row.type === 'imported-worktrees-card'
+ )
+}
+
+describe('discovery notice rows on a multi-host project', () => {
+ it('host-qualifies both inbox rows when one project is checked out on two hosts', () => {
+ const rows = buildNoticeRows({ inboxRepoIds: [repo.id, remoteRepo.id] })
+
+ expect(
+ rows.map((row) => ({
+ repoId: row.type === 'new-external-worktrees-inbox' ? row.repo.id : null,
+ hostContextLabel: 'hostContextLabel' in row ? row.hostContextLabel : undefined
+ }))
+ ).toEqual([
+ { repoId: repo.id, hostContextLabel: 'Local Mac' },
+ { repoId: remoteRepo.id, hostContextLabel: 'openclaw' }
+ ])
+ })
+
+ it('host-qualifies an imported card that shares the project with a remote inbox row', () => {
+ const rows = buildNoticeRows({
+ importedRepoIds: [remoteRepo.id],
+ inboxRepoIds: [repo.id]
+ })
+
+ expect(
+ rows.map((row) => ('hostContextLabel' in row ? row.hostContextLabel : undefined))
+ ).toEqual(['openclaw', 'Local Mac'])
+ })
+
+ it('leaves a single-host project unlabelled', () => {
+ const rows = buildNoticeRows({
+ inboxRepoIds: [repo.id],
+ worktrees: [worktree],
+ repoMap: new Map([[repo.id, repo]]),
+ projectGrouping: {
+ projects: [{ ...project, sourceRepoIds: [repo.id] }],
+ projectHostSetups: [projectHostSetups[0]!]
+ }
+ })
+
+ expect(rows).toHaveLength(1)
+ expect(rows[0]).not.toHaveProperty('hostContextLabel')
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts
index 5d3838f2f62..0dec3216534 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/build-rows.ts
@@ -16,7 +16,8 @@ import type { SectionAppendContext } from './group-sections'
import {
getLaneHostWorktreeCounts,
getLaneHostWorktreeIds,
- getMixedWorktreeHostContextLabels
+ getMixedWorktreeHostContextLabels,
+ getNoticeHostContextLabels
} from './host-labels'
import { buildProjectGroupingIndex } from './project-grouping'
import type { ProjectGroupingModel } from './project-grouping'
@@ -110,6 +111,15 @@ export function buildRows(
hostLabelById,
defaultHostId
)
+ // Why here and not per section: a notice row can land in the pinned section
+ // instead of its project's own, and the host ambiguity it resolves belongs to
+ // the project either way.
+ const noticeHostContextLabelByRepoId = getNoticeHostContextLabels(
+ new Set([...importedWorktreesByRepo.keys(), ...newExternalWorktreesInboxByRepo.keys()]),
+ repoMap,
+ projectIndex,
+ hostLabelById
+ )
const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({
groupBy,
worktrees: naturalWorktrees,
@@ -132,7 +142,8 @@ export function buildRows(
lineageById,
worktreeMap,
nestLineage,
- cyclicLineageIds
+ cyclicLineageIds,
+ noticeHostContextLabelByRepoId
)
if (groupBy === 'none') {
// Why folder workspaces gate this too: an account with only folder
@@ -208,6 +219,7 @@ export function buildRows(
newExternalWorktreesInboxByRepo,
pendingByRepo,
mixedWorktreeHostContextLabels,
+ noticeHostContextLabelByRepoId,
lineageById,
worktreeMap,
nestLineage,
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts
index e8ba7fe47f2..a556291929a 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/group-sections.ts
@@ -44,6 +44,7 @@ export type SectionAppendContext = {
newExternalWorktreesInboxByRepo: ReadonlyMap
pendingByRepo: ReadonlyMap
mixedWorktreeHostContextLabels: Map | undefined
+ noticeHostContextLabelByRepoId: Map | undefined
lineageById: Record
worktreeMap: Map
nestLineage: boolean
@@ -159,13 +160,24 @@ export function appendOrderedGroups(
for (const repoId of repoIds) {
const candidate = importedWorktreesByRepo.get(repoId)
if (candidate) {
- result.push(buildImportedWorktreesCardRow(candidate, 'repo-group'))
+ result.push(
+ buildImportedWorktreesCardRow(
+ candidate,
+ 'repo-group',
+ ctx.noticeHostContextLabelByRepoId?.get(repoId)
+ )
+ )
}
}
for (const repoId of repoIds) {
const candidate = newExternalWorktreesInboxByRepo.get(repoId)
if (candidate) {
- result.push(buildNewExternalWorktreesInboxRow(candidate))
+ result.push(
+ buildNewExternalWorktreesInboxRow(
+ candidate,
+ ctx.noticeHostContextLabelByRepoId?.get(repoId)
+ )
+ )
}
}
// Why: surface in-progress creates at the top of their own repo so the
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts
index a2949d0f806..17cb36fc524 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/host-labels.ts
@@ -7,7 +7,11 @@ import {
} from '../../../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
-import type { ProjectGroupingIndex, WorktreeGroupEntry } from './project-grouping'
+import {
+ getProjectGroupingForRepo,
+ type ProjectGroupingIndex,
+ type WorktreeGroupEntry
+} from './project-grouping'
import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id'
import type { RenderableFolderWorkspace } from './folder-workspace-lanes'
@@ -48,6 +52,44 @@ export function getMixedHostContextLabels(
return uniqueLabels.size > 1 ? labelsByRepoId : undefined
}
+/**
+ * Host labels for the sidebar's notice rows, keyed by repo id.
+ *
+ * Why not getMixedHostContextLabels: a notice row can render outside its
+ * project's own section (pinned fallback), and the ambiguity it has to resolve
+ * belongs to the project — one project checked out on several hosts emits one
+ * identical-looking row per host. So the mixed test runs per project, not per
+ * rendered group.
+ */
+export function getNoticeHostContextLabels(
+ noticeRepoIds: Iterable,
+ repoMap: Map,
+ projectIndex: ProjectGroupingIndex | null,
+ hostLabelById: ReadonlyMap | undefined
+): Map | undefined {
+ const labelsByRepoIdForProject = new Map>()
+ for (const repoId of noticeRepoIds) {
+ const label = getRepoHostLabel(repoId, repoMap, projectIndex, hostLabelById)
+ if (!label) {
+ continue
+ }
+ const projectKey = getProjectGroupingForRepo(repoId, repoMap, projectIndex).projectId ?? repoId
+ const labelsByRepoId = labelsByRepoIdForProject.get(projectKey) ?? new Map()
+ labelsByRepoId.set(repoId, label)
+ labelsByRepoIdForProject.set(projectKey, labelsByRepoId)
+ }
+ const mixed = new Map()
+ for (const labelsByRepoId of labelsByRepoIdForProject.values()) {
+ if (new Set(labelsByRepoId.values()).size < 2) {
+ continue
+ }
+ for (const [repoId, label] of labelsByRepoId) {
+ mixed.set(repoId, label)
+ }
+ }
+ return mixed.size > 0 ? mixed : undefined
+}
+
/** Keyed by host-qualified identity: two hosts sharing an id need two labels. */
export function getMixedWorktreeHostContextLabels(
worktrees: readonly Worktree[],
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/pinned-group-rows.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/pinned-group-rows.ts
index 0901d5a2562..a0755737b4f 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/pinned-group-rows.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/pinned-group-rows.ts
@@ -25,7 +25,8 @@ export function emitPinnedGroup(
lineageById: Record,
worktreeMap: Map,
nestLineage: boolean,
- cyclicLineageIds: ReadonlySet
+ cyclicLineageIds: ReadonlySet,
+ noticeHostContextLabelByRepoId?: ReadonlyMap
): void {
if (pinnedSectionWorktrees.length === 0) {
return
@@ -61,7 +62,13 @@ export function emitPinnedGroup(
for (const repoId of pinnedRepoOrder) {
const candidate = importedWorktreesByRepo.get(repoId)
if (allowImportedFallback && candidate && !renderedNaturalAnchorRepoIds.has(repoId)) {
- result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
+ result.push(
+ buildImportedWorktreesCardRow(
+ candidate,
+ 'pinned-fallback',
+ noticeHostContextLabelByRepoId?.get(repoId)
+ )
+ )
}
}
return
@@ -91,7 +98,15 @@ export function emitPinnedGroup(
for (const [repoId, index] of inserts) {
const candidate = importedWorktreesByRepo.get(repoId)
if (candidate && !renderedNaturalAnchorRepoIds.has(repoId)) {
- result.splice(index + 1, 0, buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
+ result.splice(
+ index + 1,
+ 0,
+ buildImportedWorktreesCardRow(
+ candidate,
+ 'pinned-fallback',
+ noticeHostContextLabelByRepoId?.get(repoId)
+ )
+ )
}
}
}
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts
index 5359d216ea5..479a70d4c64 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/row-builders.ts
@@ -32,25 +32,29 @@ export function buildPendingCreationRow(
export function buildImportedWorktreesCardRow(
candidate: ImportedWorktreesCardCandidate,
- placement: ImportedWorktreesCardRow['placement']
+ placement: ImportedWorktreesCardRow['placement'],
+ hostContextLabel?: string
): ImportedWorktreesCardRow {
return {
type: 'imported-worktrees-card',
key: `imported-worktrees-card:${placement}:${candidate.repo.id}`,
repo: candidate.repo,
hiddenWorktrees: candidate.hiddenWorktrees,
- placement
+ placement,
+ ...(hostContextLabel ? { hostContextLabel } : {})
}
}
export function buildNewExternalWorktreesInboxRow(
- candidate: NewExternalWorktreesInboxCandidate
+ candidate: NewExternalWorktreesInboxCandidate,
+ hostContextLabel?: string
): NewExternalWorktreesInboxRow {
return {
type: 'new-external-worktrees-inbox',
key: `new-external-worktrees-inbox:${candidate.repo.id}`,
repo: candidate.repo,
- inboxWorktrees: candidate.inboxWorktrees
+ inboxWorktrees: candidate.inboxWorktrees,
+ ...(hostContextLabel ? { hostContextLabel } : {})
}
}
diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts
index 2e45d5856aa..416e263d180 100644
--- a/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/grouping/row-types.ts
@@ -57,6 +57,8 @@ export type ImportedWorktreesCardRow = {
repo: Repo
hiddenWorktrees: DetectedWorktree[]
placement: 'repo-group' | 'pinned-fallback'
+ /** Set only when the row's project is checked out on more than one host. */
+ hostContextLabel?: string
}
export type NewExternalWorktreesInboxCandidate = {
@@ -69,6 +71,8 @@ export type NewExternalWorktreesInboxRow = {
key: string
repo: Repo
inboxWorktrees: DetectedWorktree[]
+ /** Set only when the row's project is checked out on more than one host. */
+ hostContextLabel?: string
}
export type PendingCreationRow = {
diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts
index 04e92771c0e..52164275765 100644
--- a/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts
+++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-external-worktree-cards.ts
@@ -12,12 +12,26 @@ import {
buildImportedWorktreesCardCandidates,
getHiddenImportedWorktrees
} from '../../imported-worktrees-card-candidates'
+import {
+ buildRepoCheckoutKeys,
+ getDuplicateCheckoutNoticeRepoIds
+} from '../../host-checkout-identity'
import {
suppressNewExternalWorktreeInbox,
type NewExternalWorktreesInboxActionState
} from '../../new-external-worktrees-inbox-actions'
import { buildNewExternalWorktreesInboxCandidates } from '../../new-external-worktrees-inbox-candidates'
+function omitRepoIds(
+ candidates: ReadonlyMap,
+ omittedRepoIds: ReadonlySet
+): Map {
+ if (omittedRepoIds.size === 0) {
+ return candidates instanceof Map ? candidates : new Map(candidates)
+ }
+ return new Map([...candidates].filter(([repoId]) => !omittedRepoIds.has(repoId)))
+}
+
// The two sidebar notice rows for worktrees Orca detected but does not yet show, plus the
// pending/error state their inline actions surface.
export function useSidebarExternalWorktreeCards(args: {
@@ -31,6 +45,8 @@ export function useSidebarExternalWorktreeCards(args: {
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
const settings = useAppStore((s) => s.settings)
const visibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost)
+ const sshTargetHostsById = useAppStore((s) => s.sshTargetHostsById)
+ const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const [importedWorktreeCardActionState, setImportedWorktreeCardActionState] = useState<
Map
>(new Map())
@@ -41,7 +57,7 @@ export function useSidebarExternalWorktreeCards(args: {
string | null
>(null)
- const importedWorktreesByRepo = useMemo(() => {
+ const allImportedWorktreesByRepo = useMemo(() => {
const forceVisibleRepoIds = new Set(
[...importedWorktreeCardActionState.entries()]
.filter(([, state]) => state.forceVisible)
@@ -63,7 +79,7 @@ export function useSidebarExternalWorktreeCards(args: {
visibilityDefaultsByHost,
visibleReposForRows
])
- const newExternalWorktreesInboxByRepo = useMemo(
+ const allNewExternalWorktreesInboxByRepo = useMemo(
() =>
buildNewExternalWorktreesInboxCandidates({
repos: visibleReposForRows,
@@ -81,6 +97,42 @@ export function useSidebarExternalWorktreeCards(args: {
]
)
+ // Why: one machine paired both as a direct SSH target and as a runtime
+ // environment gives a single checkout two repo records, each with its own
+ // hidden-worktree state — and so two identical notice rows for one directory.
+ const duplicateCheckoutRepoIds = useMemo(() => {
+ const noticeRepoIds = new Set([
+ ...allImportedWorktreesByRepo.keys(),
+ ...allNewExternalWorktreesInboxByRepo.keys()
+ ])
+ if (noticeRepoIds.size < 2) {
+ return new Set()
+ }
+ return getDuplicateCheckoutNoticeRepoIds({
+ repos: visibleReposForRows,
+ noticeRepoIds,
+ checkoutKeyByRepoId: buildRepoCheckoutKeys({
+ repos: visibleReposForRows,
+ sshTargetHostsById,
+ runtimeEnvironments
+ })
+ })
+ }, [
+ allImportedWorktreesByRepo,
+ allNewExternalWorktreesInboxByRepo,
+ runtimeEnvironments,
+ sshTargetHostsById,
+ visibleReposForRows
+ ])
+ const importedWorktreesByRepo = useMemo(
+ () => omitRepoIds(allImportedWorktreesByRepo, duplicateCheckoutRepoIds),
+ [allImportedWorktreesByRepo, duplicateCheckoutRepoIds]
+ )
+ const newExternalWorktreesInboxByRepo = useMemo(
+ () => omitRepoIds(allNewExternalWorktreesInboxByRepo, duplicateCheckoutRepoIds),
+ [allNewExternalWorktreesInboxByRepo, duplicateCheckoutRepoIds]
+ )
+
const setImportedWorktreeCardState = useCallback(
(projectId: string, state: ImportedWorktreeCardActionState | null) => {
setImportedWorktreeCardActionState((previous) => {
diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/notice-rows.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/notice-rows.tsx
index b240d0d93a4..7276b7058b8 100644
--- a/src/renderer/src/components/sidebar/worktree-list/rows/notice-rows.tsx
+++ b/src/renderer/src/components/sidebar/worktree-list/rows/notice-rows.tsx
@@ -62,6 +62,7 @@ export function renderImportedWorktreesVirtualRow(args: {
>
& Partial>
+
export function sshTargetLabelsEqual(
labels: Map,
- targets: Pick[]
+ targets: SshTargetMetadata[]
): boolean {
if (labels.size !== targets.length) {
return false
@@ -29,6 +33,16 @@ export function sshTargetLabelsEqual(
return targets.every((target) => labels.get(target.id) === target.label)
}
+export function sshTargetHostsEqual(
+ hostsById: Map,
+ targets: SshTargetMetadata[]
+): boolean {
+ if (hostsById.size !== targets.filter((target) => Boolean(target.host)).length) {
+ return false
+ }
+ return targets.every((target) => !target.host || hostsById.get(target.id) === target.host)
+}
+
function collectSshTargetTerminalTabIds(state: AppState, targetId: string): Set {
const targetWorktreeIds = resolveDirectSshTargetScope({
targetId,
@@ -206,6 +220,8 @@ export function buildRemovedSshTargetCleanupPatch(
const removedConnectionState = nextConnectionStates.delete(targetId)
const nextLabels = new Map(state.sshTargetLabels)
const removedLabel = nextLabels.delete(targetId)
+ const nextTargetHosts = new Map(state.sshTargetHostsById)
+ const removedTargetHost = nextTargetHosts.delete(targetId)
const nextHydrated = new Set(state.remoteWorkspaceHydratedTargetIds)
const removedHydrated = nextHydrated.delete(targetId)
const removedSyncStatus = Object.hasOwn(state.remoteWorkspaceSyncStatusByTargetId, targetId)
@@ -225,6 +241,7 @@ export function buildRemovedSshTargetCleanupPatch(
removedTransientClearBlock ||
removedConnectionState ||
removedLabel ||
+ removedTargetHost ||
removedHydrated ||
removedSyncStatus ||
removedPortForwards ||
@@ -247,6 +264,7 @@ export function buildRemovedSshTargetCleanupPatch(
: {}),
...(removedConnectionState ? { sshConnectionStates: nextConnectionStates } : {}),
...(removedLabel ? { sshTargetLabels: nextLabels } : {}),
+ ...(removedTargetHost ? { sshTargetHostsById: nextTargetHosts } : {}),
...(removedHydrated ? { remoteWorkspaceHydratedTargetIds: nextHydrated } : {}),
...(removedSyncStatus ? { remoteWorkspaceSyncStatusByTargetId: nextSyncStatus } : {}),
...(removedPortForwards ? { portForwardsByConnection: nextPortForwards } : {}),
diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts
index 9e15c81ea40..40648ad9c3d 100644
--- a/src/renderer/src/store/slices/ssh.ts
+++ b/src/renderer/src/store/slices/ssh.ts
@@ -3,13 +3,14 @@ import type { AppState } from '../types'
import type {
SshConnectionState,
PortForwardEntry,
- EnrichedDetectedPort,
- SshTarget
+ EnrichedDetectedPort
} from '../../../../shared/ssh-types'
import {
buildRemovedSshTargetCleanupPatch,
sshConnectionStatesEqual,
- sshTargetLabelsEqual
+ sshTargetHostsEqual,
+ sshTargetLabelsEqual,
+ type SshTargetMetadata
} from './ssh-target-cleanup'
export type RemoteWorkspaceSyncStatus = {
@@ -33,6 +34,10 @@ export type SshSlice = {
/** Maps target IDs to their user-facing labels. Populated during hydration
* so components can look up labels without per-component IPC calls. */
sshTargetLabels: Map
+ /** Maps target IDs to their configured hostnames. Lets host-scoped UI tell
+ * whether an SSH target and a paired runtime environment are the same
+ * machine, which labels alone cannot answer. */
+ sshTargetHostsById: Map
/** Maps REMOVED target IDs to their last known label (from re-adoption
* tombstones). Lets ghost-host UI show a friendly name instead of the raw id
* for a workspace still pinned to a deleted target. */
@@ -60,7 +65,7 @@ export type SshSlice = {
setSshConnectionState: (targetId: string, state: SshConnectionState) => void
setSshTargetLabels: (labels: Map) => void
setRemovedSshTargetLabels: (labels: Record) => void
- setSshTargetsMetadata: (targets: Pick[]) => void
+ setSshTargetsMetadata: (targets: SshTargetMetadata[]) => void
clearRemovedSshTargetState: (targetId: string) => void
markRemoteWorkspaceHydrated: (targetId: string) => void
clearRemoteWorkspaceHydrated: (targetId: string) => void
@@ -85,6 +90,7 @@ function advanceLocalSshTargetConnectionGeneration(targetId: string): void {
export const createSshSlice: StateCreator = (set) => ({
sshConnectionStates: new Map(),
sshTargetLabels: new Map(),
+ sshTargetHostsById: new Map(),
removedSshTargetLabels: new Map(),
sshTargetsHydrated: false,
remoteWorkspaceHydratedTargetIds: new Set(),
@@ -123,13 +129,19 @@ export const createSshSlice: StateCreator = (set) =>
set({ removedSshTargetLabels: new Map(Object.entries(labels)) }),
setSshTargetsMetadata: (targets) =>
set((s) => {
- if (sshTargetLabelsEqual(s.sshTargetLabels, targets)) {
+ if (
+ sshTargetLabelsEqual(s.sshTargetLabels, targets) &&
+ sshTargetHostsEqual(s.sshTargetHostsById, targets)
+ ) {
// Why: an unchanged (even empty) list is still a successful load — the
// hydration flag must flip on the first fetch of an empty target set.
return s.sshTargetsHydrated ? s : { sshTargetsHydrated: true }
}
return {
sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])),
+ sshTargetHostsById: new Map(
+ targets.flatMap((target) => (target.host ? [[target.id, target.host] as const] : []))
+ ),
sshTargetsHydrated: true
}
}),