fix(sidebar): host-qualify discovery notice rows on multi-host projects (#15546)

* 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
This commit is contained in:
Brennan Benson
2026-08-20 01:38:28 -07:00
committed by GitHub
parent 4b2ed5ddd4
commit 3e079debec
22 changed files with 782 additions and 26 deletions
@@ -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 <Icon className={className ?? 'size-3.5 shrink-0 text-muted-foreground'} />
}
@@ -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 <Icon className="size-3.5 shrink-0 text-muted-foreground" />
}
export { HostRowIcon }
/**
* One run-target row. Shares the Project picker's shape — 32px, label and
@@ -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 })
@@ -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({
/>
</Button>
<span className="min-w-0 flex-1 truncate">{lineText}</span>
{hostContextLabel && placement !== 'pinned-fallback' ? (
<span className="inline-flex min-w-0 shrink items-center gap-1">
{hostContextHostId ? (
<NoticeHostGlyph
hostId={hostContextHostId}
hostLabel={hostContextLabel}
keyboardFocusable
/>
) : null}
<span className="min-w-0 truncate text-[10px] leading-none text-muted-foreground">
{hostContextLabel}
</span>
</span>
) : null}
{onKeepHidden ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -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<HTMLDivEleme
root.render(
<NewExternalWorktreesInboxLine
repoDisplayName="orca"
hostContextLabel={overrides.hostContextLabel}
inboxCount={overrides.inboxCount ?? 24}
pending={overrides.pending ?? false}
error={overrides.error ?? null}
@@ -97,6 +99,38 @@ describe('NewExternalWorktreesInboxLine', () => {
)
})
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()
@@ -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}
</span>
<span className="min-w-0 flex-1 truncate text-left">{countLabel}</span>
{hostContextLabel ? (
<span className="inline-flex min-w-0 shrink items-center gap-1">
{hostContextHostId ? (
<NoticeHostGlyph
hostId={hostContextHostId}
hostLabel={hostContextLabel}
keyboardFocusable={false}
/>
) : null}
<span className="min-w-0 truncate text-[10px] leading-none text-muted-foreground">
{hostContextLabel}
</span>
</span>
) : null}
<ChevronRight
aria-hidden="true"
className={cn(
@@ -0,0 +1,157 @@
// @vitest-environment happy-dom
/**
* Notice rows reuse one host vocabulary: monitor for local, server for remote,
* with the worktree card's "Project on …" tooltip copy.
*/
import { act, cloneElement, type ReactElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import NoticeHostGlyph from './NoticeHostGlyph'
import en from '../../i18n/locales/en.json'
import es from '../../i18n/locales/es.json'
import ja from '../../i18n/locales/ja.json'
import ko from '../../i18n/locales/ko.json'
import zh from '../../i18n/locales/zh.json'
const runtimeStatusByEnvironmentId = new Map<string, { status?: unknown }>()
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 }) => (
<span data-testid="tooltip">{children}</span>
)
}))
const roots: Root[] = []
async function render(
hostId: string,
hostLabel = 'openclaw',
keyboardFocusable = false
): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(
<NoticeHostGlyph
hostId={hostId as never}
hostLabel={hostLabel}
keyboardFocusable={keyboardFocusable}
/>
)
})
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}}')
})
}
)
})
@@ -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>
<TooltipTrigger asChild>
<span
aria-label={keyboardFocusable ? tooltip : undefined}
className="inline-flex shrink-0 items-center rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
data-notice-host-kind={host.kind}
role={keyboardFocusable ? 'img' : undefined}
tabIndex={keyboardFocusable ? 0 : undefined}
>
<HostRowIcon
hostId={hostId}
className={`size-3 shrink-0 ${
isDisconnected ? 'text-destructive' : 'text-muted-foreground'
}`}
/>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
@@ -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<string, number> = { [sshTwin.id]: 61, [envTwin.id]: 134 }
function inboxMap(repoIds: readonly string[]): Map<string, unknown> {
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<string, Repo>
grouping?: typeof TWIN_GROUPING
}): Extract<Row, { type: 'new-external-worktrees-inbox' }>[] {
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<Row, { type: 'new-external-worktrees-inbox' }>[]
): { 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')
})
})
@@ -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,
@@ -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<string, NewExternalWorktreesInboxCandidate>
pendingByRepo: ReadonlyMap<string, PendingCreationRef[]>
mixedWorktreeHostContextLabels: Map<string, string> | undefined
noticeHostContextLabelByRepoId: Map<string, NoticeHostContext> | undefined
lineageById: Record<string, WorktreeLineage>
worktreeMap: Map<string, Worktree>
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
@@ -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, Repo>): string | null {
const repo = repoMap.get(repoId)
return repo ? getRepoExecutionHostId(repo) : null
}
function getRepoHostLabel(
repoId: string,
repoMap: Map<string, Repo>,
@@ -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<string>,
allRepoIds: Iterable<string>,
repoMap: Map<string, Repo>,
projectIndex: ProjectGroupingIndex | null,
hostLabelById: ReadonlyMap<string, string> | undefined
): Map<string, NoticeHostContext> | 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<string, Set<string>>()
const labelsByRepoId = new Map<string, NoticeHostContext>()
const projectKeyByRepoId = new Map<string, string>()
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<string>()
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<string, NoticeHostContext>()
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[],
@@ -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<string, WorktreeLineage>,
worktreeMap: Map<string, Worktree>,
nestLineage: boolean,
cyclicLineageIds: ReadonlySet<string>
cyclicLineageIds: ReadonlySet<string>,
noticeHostContextLabelByRepoId?: ReadonlyMap<string, NoticeHostContext>
): 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)
)
)
}
}
}
@@ -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 }
: {})
}
}
@@ -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 = {
@@ -62,6 +62,8 @@ export function renderImportedWorktreesVirtualRow(args: {
>
<ImportedWorktreesVisibilityLine
repoDisplayName={row.repo.displayName}
hostContextLabel={row.hostContextLabel}
hostContextHostId={row.hostContextHostId}
hiddenWorktrees={row.hiddenWorktrees}
placement={row.placement}
pending={actionState?.pending ?? false}
@@ -94,6 +96,8 @@ export function renderNewExternalWorktreesInboxVirtualRow(args: {
>
<NewExternalWorktreesInboxLine
repoDisplayName={row.repo.displayName}
hostContextLabel={row.hostContextLabel}
hostContextHostId={row.hostContextHostId}
inboxCount={row.inboxWorktrees.length}
pending={actionState?.pending ?? false}
error={actionState?.error ?? null}
+8 -1
View File
@@ -5727,7 +5727,14 @@
"5b90e4a2f6": "hidden worktrees",
"7f18c5b0d3": "Review {{value0}} hidden worktree in {{value1}}",
"4e2b7a9c05": "Review {{value0}} hidden worktrees in {{value1}}",
"c3e8a1f4b2": "Don't show again"
"c3e8a1f4b2": "Don't show again",
"6c07f3a91e": "{{value0}} on {{value1}}"
},
"NoticeHostGlyph": {
"hostDisconnected": "{{hostName}} disconnected",
"sshHostProject": "Project on SSH host {{hostName}}",
"localHostProject": "Project on this host",
"runtimeHostProject": "Project on {{hostName}}"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "Could not keep external worktrees hidden. Try again.",
+6
View File
@@ -5059,6 +5059,12 @@
"5e1b8d3f62": "Ocultar worktrees externos permanentemente",
"c3e8a1f4b2": "No volver a mostrar"
},
"NoticeHostGlyph": {
"hostDisconnected": "{{hostName}} está desconectado",
"sshHostProject": "Proyecto en el host SSH {{hostName}}",
"localHostProject": "Proyecto en este host",
"runtimeHostProject": "Proyecto en {{hostName}}"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "No se pudieron mantener ocultos los worktrees externos. Inténtalo de nuevo.",
"b7e4d1a062": "No se pudieron importar los worktrees externos. Inténtalo de nuevo.",
+6
View File
@@ -5059,6 +5059,12 @@
"5e1b8d3f62": "外部ワークツリーを完全に非表示にする",
"c3e8a1f4b2": "今後表示しない"
},
"NoticeHostGlyph": {
"hostDisconnected": "{{hostName}} が切断されました",
"sshHostProject": "SSH ホスト {{hostName}} 上のプロジェクト",
"localHostProject": "このホスト上のプロジェクト",
"runtimeHostProject": "{{hostName}} 上のプロジェクト"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "外部ワークツリーを非表示のままにできませんでした。もう一度お試しください。",
"b7e4d1a062": "外部ワークツリーをインポートできませんでした。もう一度お試しください。",
+6
View File
@@ -5061,6 +5061,12 @@
"5e1b8d3f62": "외부 워크트리를 영구적으로 숨기기",
"c3e8a1f4b2": "다시 표시하지 않기"
},
"NoticeHostGlyph": {
"hostDisconnected": "{{hostName}} 연결 끊김",
"sshHostProject": "SSH 호스트 {{hostName}}의 프로젝트",
"localHostProject": "이 호스트의 프로젝트",
"runtimeHostProject": "{{hostName}}의 프로젝트"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "외부 워크트리를 숨김 상태로 유지할 수 없습니다. 다시 시도하세요.",
"b7e4d1a062": "외부 워크트리를 가져올 수 없습니다. 다시 시도하세요.",
+6
View File
@@ -5071,6 +5071,12 @@
"5e1b8d3f62": "永久隐藏外部工作树",
"c3e8a1f4b2": "不再显示"
},
"NoticeHostGlyph": {
"hostDisconnected": "{{hostName}} 已断开连接",
"sshHostProject": "SSH 主机 {{hostName}} 上的项目",
"localHostProject": "此主机上的项目",
"runtimeHostProject": "{{hostName}} 上的项目"
},
"newExternalWorktreesInboxActions": {
"a11c2f6d89": "无法保持外部工作树隐藏。请重试。",
"b7e4d1a062": "无法导入外部工作树。请重试。",
@@ -11,6 +11,9 @@ describe('worktree visibility locales', () => {
})
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':