From 3e079debec65aa958fc69bbf3ffa8979662688ef Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 20 Aug 2026 01:38:28 -0700
Subject: [PATCH] fix(sidebar): host-qualify discovery notice rows on
multi-host projects (#15546)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(sidebar): host-qualify discovery notice rows and collapse one checkout's twins
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.
* fix(sidebar): drop the machine-identity collapse, gate notice labels on host ids
Replaces this branch's second change after a plan review found it has no
precedent and eight concrete failure modes.
Deleted: the (hostname, path) checkout key that collapsed two repo records
believed to be one machine. Orca models a direct SSH target and a paired
runtime environment as different execution hosts everywhere else; that change
asserted sameness by resolving strings a user typed in two places. It also
dropped rows (a differing count vanished with the shadowed record), flipped
with the sidebar host filter, ignored port and user so a host and a container
on it could merge, tie-broke on repo-store order, was disabled in the one case
Orca can prove (a tunnelled pairing answers on loopback) and fired only on
coincidence, and left the visibility dialog showing state the sidebar had
hidden. Its module also carried a literal NUL byte, so git classified the file
as binary and the diff was unreviewable.
Kept, with two corrections: notice rows still carry a host label, but the gate
now counts distinct host ids rather than distinct label strings — two hosts
sharing one user-facing label is exactly when the rows are hardest to tell
apart — and membership is read from the unfiltered repo universe rather than
the host-filtered notice candidates, so a label no longer appears and
disappears with the filter.
Two hosts that share a label still render the same label. Disambiguating that
is a shared concern across worktree badges, host headers, and host-filter
options, and needs its own design; three verification passes each found a
different hole in doing it here. Follow-ups: general host-label collision, and
the repo-record duplication that produces the twin rows in the first place.
* fix(i18n): catalog notice host scope copy
* feat(sidebar): show each notice row's host with the project-on-host glyph
Notice rows on a multi-host project already carried a host label, but two
hosts can share one user-facing name, and the label truncates first in a
narrow sidebar. Each row now also carries its host's glyph.
Deliberately the same indicator worktree cards use (worktree-card-header):
a Server glyph, ServerOff when a paired runtime has no live status, and a
"Project on ..." tooltip naming the host — SSH and runtime keep their
distinct tooltip wording. Local hosts draw nothing, as on the cards.
The glyph is shrink-0, so unlike the text label it survives the sidebar
narrowing, and the row keeps an identifying mark either way.
Rows now carry the host id alongside the label, since the label alone cannot
select a glyph or its tooltip. Catalog entries for the new copy ship with the
change rather than relying on inline fallbacks.
* refactor(sidebar): draw notice-row hosts with the shared host glyph
Follow-up to the notice-row host indicator: use the one glyph vocabulary the
app already has instead of a second copy of it.
HostRowIcon — a monitor for this computer, a server for anything remote — was
private to the composer's run-target rows. Moved to a shared home and reused,
so the sidebar and the composer cannot drift apart. The run-target module
re-exports it, leaving its own call sites untouched.
Every notice row now gets a glyph, local included, so no row is the odd one
out; the tooltip still names the host and says when a paired runtime has no
live status. Same size and tone tokens across kinds, so no row reads as
decorated relative to its neighbours.
* fix(sidebar): make notice host glyphs accessible
---
src/renderer/src/components/host-row-icon.tsx | 16 ++
.../new-workspace/RunTargetComboboxRow.tsx | 11 +-
.../ImportedWorktreesVisibilityLine.test.tsx | 20 ++
.../ImportedWorktreesVisibilityLine.tsx | 32 ++-
.../NewExternalWorktreesInboxLine.test.tsx | 34 +++
.../sidebar/NewExternalWorktreesInboxLine.tsx | 37 ++-
.../sidebar/NoticeHostGlyph.test.tsx | 157 +++++++++++++
.../components/sidebar/NoticeHostGlyph.tsx | 86 +++++++
...ree-list-groups-notice-host-labels.test.ts | 220 ++++++++++++++++++
.../worktree-list/grouping/build-rows.ts | 18 +-
.../worktree-list/grouping/group-sections.ts | 17 +-
.../worktree-list/grouping/host-labels.ts | 75 +++++-
.../grouping/pinned-group-rows.ts | 22 +-
.../worktree-list/grouping/row-builders.ts | 17 +-
.../worktree-list/grouping/row-types.ts | 6 +
.../worktree-list/rows/notice-rows.tsx | 4 +
src/renderer/src/i18n/locales/en.json | 9 +-
src/renderer/src/i18n/locales/es.json | 6 +
src/renderer/src/i18n/locales/ja.json | 6 +
src/renderer/src/i18n/locales/ko.json | 6 +
src/renderer/src/i18n/locales/zh.json | 6 +
.../i18n/worktree-visibility-locales.test.ts | 3 +
22 files changed, 782 insertions(+), 26 deletions(-)
create mode 100644 src/renderer/src/components/host-row-icon.tsx
create mode 100644 src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx
create mode 100644 src/renderer/src/components/sidebar/NoticeHostGlyph.tsx
create mode 100644 src/renderer/src/components/sidebar/worktree-list-groups-notice-host-labels.test.ts
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':