diff --git a/src/renderer/src/components/host-row-icon.tsx b/src/renderer/src/components/host-row-icon.tsx
new file mode 100644
index 00000000000..7e7d519f60a
--- /dev/null
+++ b/src/renderer/src/components/host-row-icon.tsx
@@ -0,0 +1,16 @@
+import React from 'react'
+import { Monitor, Server } from 'lucide-react'
+
+import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
+
+/** The local machine isn't a server — a monitor glyph reads as "this computer". */
+export function HostRowIcon({
+ hostId,
+ className
+}: {
+ hostId: ExecutionHostId
+ className?: string
+}): React.JSX.Element {
+ const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server
+ return
+}
diff --git a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx
index 4d7cb3df50f..dde3ae66b28 100644
--- a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx
+++ b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx
@@ -1,17 +1,14 @@
import React from 'react'
-import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react'
+import { AlertTriangle, ChevronRight, LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
-import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
+import type { ExecutionHostId } from '../../../../shared/execution-host'
import { ProjectOptionDetail } from './ProjectComboboxRow'
import { translate } from '@/i18n/i18n'
+import { HostRowIcon } from '../host-row-icon'
-/** The local machine isn't a server — a monitor glyph reads as "this computer". */
-export function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element {
- const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server
- return
-}
+export { HostRowIcon }
/**
* One run-target row. Shares the Project picker's shape — 32px, label and
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..9418b29b5ce 100644
--- a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx
+++ b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx
@@ -4,6 +4,8 @@ import { ChevronRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
+import NoticeHostGlyph from './NoticeHostGlyph'
+import type { ExecutionHostId } from '../../../../shared/execution-host'
import { getExternalWorktreeParentPath } from '../../../../shared/external-worktree-visibility'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
import { translate } from '@/i18n/i18n'
@@ -19,6 +21,10 @@ 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
+ hostContextHostId?: ExecutionHostId
hiddenWorktrees: readonly ImportedWorktreeVisibilityPreview[]
placement: ImportedWorktreesVisibilityPlacement
pending: boolean
@@ -74,6 +80,8 @@ export function groupWorktreesByParentPath(
export default function ImportedWorktreesVisibilityLine({
repoDisplayName,
+ hostContextLabel,
+ hostContextHostId,
hiddenWorktrees,
placement,
pending,
@@ -89,7 +97,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 +109,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 +145,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 +156,20 @@ export default function ImportedWorktreesVisibilityLine({
/>
{lineText}
+ {hostContextLabel && placement !== 'pinned-fallback' ? (
+
+ {hostContextHostId ? (
+
+ ) : null}
+
+ {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..33fe674575f 100644
--- a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx
+++ b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx
@@ -4,10 +4,16 @@ import { ChevronRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
+import NoticeHostGlyph from './NoticeHostGlyph'
+import type { ExecutionHostId } from '../../../../shared/execution-host'
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
+ hostContextHostId?: ExecutionHostId
inboxCount: number
pending: boolean
error: string | null
@@ -18,6 +24,8 @@ type NewExternalWorktreesInboxLineProps = {
export default function NewExternalWorktreesInboxLine({
repoDisplayName,
+ hostContextLabel,
+ hostContextHostId,
inboxCount,
pending,
error,
@@ -29,10 +37,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 +65,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 +100,20 @@ export default function NewExternalWorktreesInboxLine({
{inboxCount}
{countLabel}
+ {hostContextLabel ? (
+
+ {hostContextHostId ? (
+
+ ) : null}
+
+ {hostContextLabel}
+
+
+ ) : null}
()
+
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: unknown) => unknown) => selector({ runtimeStatusByEnvironmentId })
+}))
+
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: ReactElement<{ 'data-testid'?: string }> }) =>
+ cloneElement(children, { 'data-testid': 'tooltip-trigger' }),
+ TooltipContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+}))
+
+const roots: Root[] = []
+
+async function render(
+ hostId: string,
+ hostLabel = 'openclaw',
+ keyboardFocusable = false
+): Promise {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ roots.push(root)
+ await act(async () => {
+ root.render(
+
+ )
+ })
+ return container
+}
+
+describe('NoticeHostGlyph', () => {
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ runtimeStatusByEnvironmentId.clear()
+ })
+
+ afterEach(() => {
+ roots.splice(0).forEach((root) => act(() => root.unmount()))
+ document.body.replaceChildren()
+ })
+
+ it('names the SSH host it would act on', async () => {
+ const container = await render('ssh:openclaw-target')
+
+ expect(container.querySelector('[data-notice-host-kind="ssh"]')).not.toBeNull()
+ expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe(
+ 'Project on SSH host openclaw'
+ )
+ })
+
+ it('names the paired runtime separately from the SSH host of the same name', async () => {
+ runtimeStatusByEnvironmentId.set('openclaw-env', { status: 'ready' })
+ const container = await render('runtime:openclaw-env')
+
+ expect(container.querySelector('[data-notice-host-kind="runtime"]')).not.toBeNull()
+ expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe(
+ 'Project on openclaw'
+ )
+ })
+
+ it('marks a paired runtime with no live status as disconnected', async () => {
+ const container = await render('runtime:openclaw-env')
+
+ expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe(
+ 'openclaw disconnected'
+ )
+ })
+
+ it('gives the local host the monitor glyph the run-target rows use', async () => {
+ const container = await render('local', 'Local Mac')
+
+ expect(container.querySelector('[data-notice-host-kind="local"]')).not.toBeNull()
+ expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe(
+ 'Project on this host'
+ )
+ })
+
+ it('makes a passive row glyph keyboard reachable with an accessible name', async () => {
+ const container = await render('ssh:openclaw-target', 'openclaw', true)
+ const trigger = container.querySelector('[data-testid="tooltip-trigger"]')
+
+ expect(trigger?.getAttribute('tabindex')).toBe('0')
+ expect(trigger?.getAttribute('role')).toBe('img')
+ expect(trigger?.getAttribute('aria-label')).toBe('Project on SSH host openclaw')
+ })
+
+ it('does not add a nested tab stop when the glyph is inside a button', async () => {
+ const container = await render('ssh:openclaw-target')
+
+ expect(
+ container.querySelector('[data-testid="tooltip-trigger"]')?.hasAttribute('tabindex')
+ ).toBe(false)
+ })
+
+ it('draws one glyph vocabulary: a monitor for local, a server for remote', async () => {
+ const local = await render('local', 'Local Mac')
+ const remote = await render('ssh:openclaw-target')
+
+ const glyph = (container: HTMLDivElement): string =>
+ container.querySelector('svg')?.getAttribute('class') ?? ''
+ // The same vocabulary the run-target rows use: this computer vs a server.
+ expect(glyph(local)).toContain('lucide-monitor')
+ expect(glyph(remote)).toContain('lucide-server')
+ // Same size and tone tokens, so neither row reads as decorated.
+ const tokens = (value: string): string[] =>
+ value.split(' ').filter((entry) => !entry.startsWith('lucide'))
+ expect(tokens(glyph(local))).toEqual(tokens(glyph(remote)))
+ })
+
+ it('keeps its copy in the English catalog', async () => {
+ // A key referenced only in the component silently falls back to its inline
+ // default and never reaches translators.
+ expect(en.auto.components.sidebar.NoticeHostGlyph).toMatchObject({
+ hostDisconnected: '{{hostName}} disconnected',
+ sshHostProject: 'Project on SSH host {{hostName}}',
+ localHostProject: 'Project on this host',
+ runtimeHostProject: 'Project on {{hostName}}'
+ })
+ })
+
+ it.each(Object.entries({ es, ja, ko, zh }))(
+ 'keeps its copy in the %s catalog',
+ (_locale, catalog) => {
+ expect(catalog.auto.components.sidebar.NoticeHostGlyph).toMatchObject({
+ hostDisconnected: expect.stringContaining('{{hostName}}'),
+ sshHostProject: expect.stringContaining('{{hostName}}'),
+ localHostProject: expect.any(String),
+ runtimeHostProject: expect.stringContaining('{{hostName}}')
+ })
+ }
+ )
+})
diff --git a/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx
new file mode 100644
index 00000000000..db1616203dd
--- /dev/null
+++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx
@@ -0,0 +1,86 @@
+import React from 'react'
+
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { HostRowIcon } from '../host-row-icon'
+import { useAppStore } from '@/store'
+import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
+import { translate } from '@/i18n/i18n'
+
+type NoticeHostGlyphProps = {
+ hostId: ExecutionHostId
+ hostLabel: string
+ keyboardFocusable: boolean
+}
+
+/**
+ * The host indicator for a discovery-notice row.
+ *
+ * Deliberately the one host glyph vocabulary the composer's run-target rows
+ * already use (HostRowIcon: a monitor for this computer, a server for anything
+ * remote), plus the worktree card's "Project on …" tooltip. Every row gets one,
+ * including local, so no row is the odd one out.
+ */
+export default function NoticeHostGlyph({
+ hostId,
+ hostLabel,
+ keyboardFocusable
+}: NoticeHostGlyphProps): React.JSX.Element | null {
+ const host = parseExecutionHostId(hostId)
+ const isDisconnected = useAppStore((s) => {
+ if (host?.kind !== 'runtime') {
+ return false
+ }
+ return !s.runtimeStatusByEnvironmentId.get(host.environmentId)?.status
+ })
+
+ if (!host) {
+ return null
+ }
+
+ const tooltip = isDisconnected
+ ? translate(
+ 'auto.components.sidebar.NoticeHostGlyph.hostDisconnected',
+ '{{hostName}} disconnected',
+ { hostName: hostLabel }
+ )
+ : host.kind === 'ssh'
+ ? translate(
+ 'auto.components.sidebar.NoticeHostGlyph.sshHostProject',
+ 'Project on SSH host {{hostName}}',
+ { hostName: hostLabel }
+ )
+ : host.kind === 'local'
+ ? translate(
+ 'auto.components.sidebar.NoticeHostGlyph.localHostProject',
+ 'Project on this host'
+ )
+ : translate(
+ 'auto.components.sidebar.NoticeHostGlyph.runtimeHostProject',
+ 'Project on {{hostName}}',
+ { hostName: hostLabel }
+ )
+
+ return (
+
+
+
+
+
+
+
+ {tooltip}
+
+
+ )
+}
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..8bbf99f9260
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts
@@ -0,0 +1,220 @@
+/**
+ * A project checked out on several hosts emits one discovery-notice row per
+ * checkout. Those rows only ever named the project, so a sidebar with paired
+ * remote hosts showed identical "N hidden worktrees" buttons with no way to
+ * tell which machine either belonged to.
+ *
+ * Two hosts can also share one user-facing label, which is when the rows are
+ * hardest to tell apart — so the gate counts distinct host ids, and it reads
+ * them from the unfiltered repo universe rather than the host-filtered notice
+ * candidates, or a label would appear and disappear with the sidebar filter.
+ */
+import { describe, expect, it } from 'vitest'
+import { buildRows } from './worktree-list/grouping/build-rows'
+import { getNoticeHostContextLabels } from './worktree-list/grouping/host-labels'
+import { buildProjectGroupingIndex } from './worktree-list/grouping/project-grouping'
+import { repo, worktree, project, projectHostSetups } from './worktree-list-groups-test-fixtures'
+import type { ExecutionHostId } from '../../../../shared/execution-host'
+import type { ProjectHostSetup } from '../../../../shared/project-types'
+import type { Repo } from '../../../../shared/repo-types'
+import type { DetectedWorktree, Worktree } from '../../../../shared/worktree/types'
+import type { Row } from './worktree-list/grouping/row-types'
+
+const SSH_HOST_ID: ExecutionHostId = 'ssh:openclaw-target'
+const ENV_HOST_ID: ExecutionHostId = 'runtime:openclaw-env'
+
+/** Both twins display the same label: the reporting account's shape. */
+const HOST_LABELS = new Map([
+ ['local', 'Local Mac'],
+ [SSH_HOST_ID, 'openclaw'],
+ [ENV_HOST_ID, 'openclaw']
+])
+
+const sshTwin: Repo = {
+ ...repo,
+ id: 'repo-ssh-twin',
+ path: '/home/brennan/orca',
+ connectionId: 'openclaw-target'
+}
+const envTwin: Repo = {
+ ...repo,
+ id: 'repo-env-twin',
+ path: '/home/brennan/orca',
+ connectionId: null,
+ executionHostId: ENV_HOST_ID
+}
+
+function setupFor(target: Repo, hostId: ExecutionHostId): ProjectHostSetup {
+ return {
+ ...projectHostSetups[0]!,
+ id: target.id,
+ projectId: project.id,
+ hostId,
+ repoId: target.id,
+ path: target.path,
+ displayName: target.displayName
+ }
+}
+
+const TWIN_GROUPING = {
+ projects: [{ ...project, sourceRepoIds: [repo.id, sshTwin.id, envTwin.id] }],
+ projectHostSetups: [
+ projectHostSetups[0]!,
+ setupFor(sshTwin, SSH_HOST_ID),
+ setupFor(envTwin, ENV_HOST_ID)
+ ]
+}
+
+const TWIN_REPO_MAP = new Map([
+ [repo.id, repo],
+ [sshTwin.id, sshTwin],
+ [envTwin.id, envTwin]
+])
+
+function detected(path: string): DetectedWorktree {
+ return { path, visible: false } as DetectedWorktree
+}
+
+/** Counts differ per record — the reporting account's 61 vs 134. */
+const INBOX_COUNTS: Record = { [sshTwin.id]: 61, [envTwin.id]: 134 }
+
+function inboxMap(repoIds: readonly string[]): Map {
+ return new Map(
+ repoIds.map((repoId) => [
+ repoId,
+ {
+ repo: TWIN_REPO_MAP.get(repoId)!,
+ inboxWorktrees: Array.from({ length: INBOX_COUNTS[repoId] ?? 1 }, (_unused, index) =>
+ detected(`/inbox/${repoId}/${index}`)
+ )
+ }
+ ])
+ )
+}
+
+function noticeRows(args: {
+ /** Host-filtered: only these records still have notice candidates. */
+ eligibleRepoIds: readonly string[]
+ worktrees?: Worktree[]
+ repoMap?: Map
+ grouping?: typeof TWIN_GROUPING
+}): Extract[] {
+ const rows = buildRows(
+ 'repo',
+ args.worktrees ?? [worktree],
+ args.repoMap ?? TWIN_REPO_MAP,
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ undefined,
+ false,
+ undefined,
+ [],
+ new Set(),
+ new Map(),
+ inboxMap(args.eligibleRepoIds) as never,
+ [],
+ args.grouping ?? TWIN_GROUPING,
+ [],
+ HOST_LABELS
+ )
+ return rows.filter((row) => row.type === 'new-external-worktrees-inbox')
+}
+
+function summarize(
+ rows: Extract[]
+): { repoId: string; label: string | undefined; hostId: string | undefined; count: number }[] {
+ return rows.map((row) => ({
+ repoId: row.repo.id,
+ label: row.hostContextLabel,
+ // Carried so the row can draw the host glyph; two hosts sharing a label
+ // differ only here.
+ hostId: row.hostContextHostId,
+ count: row.inboxWorktrees.length
+ }))
+}
+
+describe('discovery notice rows on a multi-host project', () => {
+ it('labels both rows when two distinct hosts share one label', () => {
+ // Kills the label-counting gate, so the project must contain ONLY the two
+ // same-label hosts: a third host with a different label would supply the
+ // label diversity the old gate needed and the test would pass either way.
+ expect(
+ summarize(
+ noticeRows({
+ eligibleRepoIds: [sshTwin.id, envTwin.id],
+ repoMap: new Map([
+ [sshTwin.id, sshTwin],
+ [envTwin.id, envTwin]
+ ]),
+ grouping: {
+ projects: [{ ...project, sourceRepoIds: [sshTwin.id, envTwin.id] }],
+ projectHostSetups: [setupFor(sshTwin, SSH_HOST_ID), setupFor(envTwin, ENV_HOST_ID)]
+ }
+ })
+ )
+ ).toEqual([
+ { repoId: sshTwin.id, label: 'openclaw', hostId: SSH_HOST_ID, count: 61 },
+ { repoId: envTwin.id, label: 'openclaw', hostId: ENV_HOST_ID, count: 134 }
+ ])
+ })
+
+ it('labels a lone eligible row on a project that spans hosts', () => {
+ // Kills notice-row-scoped membership: only one record emits a row, but the
+ // project still spans hosts, so the row must say which host it is.
+ expect(summarize(noticeRows({ eligibleRepoIds: [envTwin.id] }))).toEqual([
+ { repoId: envTwin.id, label: 'openclaw', hostId: ENV_HOST_ID, count: 134 }
+ ])
+ })
+
+ it('keeps each row its own label and count under either host filter', () => {
+ // Kills the collapse (both rows survive with distinct counts) and proves the
+ // gate reads the unfiltered universe (each filtered survivor keeps its label).
+ expect(summarize(noticeRows({ eligibleRepoIds: [sshTwin.id] }))).toEqual([
+ { repoId: sshTwin.id, label: 'openclaw', hostId: SSH_HOST_ID, count: 61 }
+ ])
+ expect(summarize(noticeRows({ eligibleRepoIds: [envTwin.id] }))).toEqual([
+ { repoId: envTwin.id, label: 'openclaw', hostId: ENV_HOST_ID, count: 134 }
+ ])
+ })
+
+ it('returns labels for exactly the eligible records, never the filtered-out ones', () => {
+ // A row-level test cannot see an extra ineligible entry, because no row
+ // consumes it; only exact map membership pins the intersection.
+ const index = buildProjectGroupingIndex(TWIN_GROUPING)
+
+ for (const eligible of [[sshTwin.id], [envTwin.id], [sshTwin.id, envTwin.id]]) {
+ const labels = getNoticeHostContextLabels(
+ eligible,
+ TWIN_REPO_MAP.keys(),
+ TWIN_REPO_MAP,
+ index,
+ HOST_LABELS
+ )
+ expect([...(labels?.keys() ?? [])]).toEqual(eligible)
+ }
+ })
+
+ it('leaves a single-host project unlabelled even with several records on it', () => {
+ // Regression pin: guards a future implementation that counts records
+ // instead of distinct host ids.
+ const secondLocal: Repo = { ...repo, id: 'repo-local-2', path: '/tmp/orca-second' }
+ const rows = noticeRows({
+ eligibleRepoIds: [repo.id],
+ repoMap: new Map([
+ [repo.id, repo],
+ [secondLocal.id, secondLocal]
+ ]),
+ grouping: {
+ projects: [{ ...project, sourceRepoIds: [repo.id, secondLocal.id] }],
+ projectHostSetups: [projectHostSetups[0]!, setupFor(secondLocal, 'local')]
+ }
+ })
+
+ 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..77353708708 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,17 @@ 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. repoMap is the unfiltered universe; the candidate
+ // maps are host-filter scoped, so only they gate eligibility.
+ const noticeHostContextLabelByRepoId = getNoticeHostContextLabels(
+ new Set([...importedWorktreesByRepo.keys(), ...newExternalWorktreesInboxByRepo.keys()]),
+ repoMap.keys(),
+ repoMap,
+ projectIndex,
+ hostLabelById
+ )
const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({
groupBy,
worktrees: naturalWorktrees,
@@ -132,7 +144,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 +221,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..bbba41b609b 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
@@ -8,6 +8,7 @@ import {
} from '../../workspace-status'
import { PROJECT_GROUP_META, PR_GROUP_META } from './group-keys'
import type { PRGroupKey } from './group-keys'
+import type { NoticeHostContext } from './host-labels'
import {
getLaneHostWorktreeCounts,
getLaneHostWorktreeIds,
@@ -44,6 +45,7 @@ export type SectionAppendContext = {
newExternalWorktreesInboxByRepo: ReadonlyMap
pendingByRepo: ReadonlyMap
mixedWorktreeHostContextLabels: Map | undefined
+ noticeHostContextLabelByRepoId: Map | undefined
lineageById: Record
worktreeMap: Map
nestLineage: boolean
@@ -159,13 +161,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..e8265667cbb 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,10 +7,19 @@ 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'
+function getRepoHostId(repoId: string, repoMap: Map): string | null {
+ const repo = repoMap.get(repoId)
+ return repo ? getRepoExecutionHostId(repo) : null
+}
+
function getRepoHostLabel(
repoId: string,
repoMap: Map,
@@ -48,6 +57,70 @@ 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 resolves
+ * 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.
+ *
+ * Two inputs, deliberately: `allRepoIds` is the unfiltered universe that decides
+ * whether a project spans hosts, and `noticeRepoIds` is the host-filtered set
+ * eligible for a label. Deriving both from the filtered set would make a label
+ * appear and disappear with the sidebar's host filter.
+ */
+export type NoticeHostContext = {
+ label: string
+ /** Carried so the row can draw the same host glyph and "Project on …"
+ * tooltip worktree cards use, which the label alone cannot select. */
+ hostId: ExecutionHostId
+}
+
+export function getNoticeHostContextLabels(
+ noticeRepoIds: Iterable,
+ allRepoIds: Iterable,
+ repoMap: Map,
+ projectIndex: ProjectGroupingIndex | null,
+ hostLabelById: ReadonlyMap | undefined
+): Map | undefined {
+ const eligible = new Set(noticeRepoIds)
+ if (eligible.size === 0) {
+ return undefined
+ }
+ // Why host ids and not labels: two hosts can share one user-facing label, and
+ // that project spans hosts just the same — counting labels hides exactly the
+ // case where the rows are hardest to tell apart.
+ const hostIdsForProject = new Map>()
+ const labelsByRepoId = new Map()
+ const projectKeyByRepoId = new Map()
+ for (const repoId of allRepoIds) {
+ const label = getRepoHostLabel(repoId, repoMap, projectIndex, hostLabelById)
+ if (!label) {
+ continue
+ }
+ const projectKey = getProjectGroupingForRepo(repoId, repoMap, projectIndex).projectId ?? repoId
+ const hostId = projectIndex?.setupByRepoId.get(repoId)?.hostId ?? getRepoHostId(repoId, repoMap)
+ if (hostId) {
+ const hostIds = hostIdsForProject.get(projectKey) ?? new Set()
+ hostIds.add(hostId)
+ hostIdsForProject.set(projectKey, hostIds)
+ }
+ if (eligible.has(repoId) && hostId) {
+ labelsByRepoId.set(repoId, { label, hostId: hostId as ExecutionHostId })
+ projectKeyByRepoId.set(repoId, projectKey)
+ }
+ }
+ const mixed = new Map()
+ for (const [repoId, context] of labelsByRepoId) {
+ const projectKey = projectKeyByRepoId.get(repoId)
+ if (projectKey && (hostIdsForProject.get(projectKey)?.size ?? 0) > 1) {
+ mixed.set(repoId, context)
+ }
+ }
+ 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..2bba99116dd 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
@@ -5,6 +5,7 @@ import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-h
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { PINNED_GROUP_KEY, PINNED_GROUP_META } from './group-keys'
import { appendWorktreeRows, buildImportedWorktreesCardRow } from './row-builders'
+import type { NoticeHostContext } from './host-labels'
import type { ImportedWorktreesCardCandidate, Row } from './row-types'
/**
@@ -25,7 +26,8 @@ export function emitPinnedGroup(
lineageById: Record,
worktreeMap: Map,
nestLineage: boolean,
- cyclicLineageIds: ReadonlySet
+ cyclicLineageIds: ReadonlySet,
+ noticeHostContextLabelByRepoId?: ReadonlyMap
): void {
if (pinnedSectionWorktrees.length === 0) {
return
@@ -61,7 +63,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 +99,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..e62d0f9ec70 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
@@ -5,6 +5,7 @@ import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-
import { isValidResolvedWorktreeLineageEdge } from '../../../../../../shared/resolved-worktree-lineage'
import { getProjectedWorktreeLineage } from '../../worktree-lineage-projection'
import { getWorktreeLineageGroupKey } from './group-keys'
+import type { NoticeHostContext } from './host-labels'
import type { RenderableFolderWorkspace } from './folder-workspace-lanes'
import type {
FolderWorkspaceRow,
@@ -32,25 +33,33 @@ export function buildPendingCreationRow(
export function buildImportedWorktreesCardRow(
candidate: ImportedWorktreesCardCandidate,
- placement: ImportedWorktreesCardRow['placement']
+ placement: ImportedWorktreesCardRow['placement'],
+ hostContext?: NoticeHostContext
): ImportedWorktreesCardRow {
return {
type: 'imported-worktrees-card',
key: `imported-worktrees-card:${placement}:${candidate.repo.id}`,
repo: candidate.repo,
hiddenWorktrees: candidate.hiddenWorktrees,
- placement
+ placement,
+ ...(hostContext
+ ? { hostContextLabel: hostContext.label, hostContextHostId: hostContext.hostId }
+ : {})
}
}
export function buildNewExternalWorktreesInboxRow(
- candidate: NewExternalWorktreesInboxCandidate
+ candidate: NewExternalWorktreesInboxCandidate,
+ hostContext?: NoticeHostContext
): NewExternalWorktreesInboxRow {
return {
type: 'new-external-worktrees-inbox',
key: `new-external-worktrees-inbox:${candidate.repo.id}`,
repo: candidate.repo,
- inboxWorktrees: candidate.inboxWorktrees
+ inboxWorktrees: candidate.inboxWorktrees,
+ ...(hostContext
+ ? { hostContextLabel: hostContext.label, hostContextHostId: hostContext.hostId }
+ : {})
}
}
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..92deca9e964 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,9 @@ 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
+ hostContextHostId?: ExecutionHostId
}
export type NewExternalWorktreesInboxCandidate = {
@@ -69,6 +72,9 @@ 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
+ hostContextHostId?: ExecutionHostId
}
export type PendingCreationRow = {
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..89a93c39e80 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,8 @@ export function renderImportedWorktreesVirtualRow(args: {
>
{
})
it('keeps current fallback-derived keys in the English catalog', () => {
+ expect(en.auto.components.sidebar.NewExternalWorktreesInboxLine).toMatchObject({
+ '6c07f3a91e': '{{value0}} on {{value1}}'
+ })
expect(en.auto.components.sidebar.WorktreeVisibilityHelpPopover).toMatchObject({
ec1e6a10fb: 'Other worktrees start hidden to avoid unexpected sidebar clutter.',
'1c68c9cf77':