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 00000000000..1ddb3384f4f
Binary files /dev/null and b/src/renderer/src/components/sidebar/host-checkout-identity.ts differ
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
}
}),