From 3bd7acaf3fd361e00df041bf6db3184404720237 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:01:47 -0700
Subject: [PATCH] feat(sidebar): show each notice row's host with the
project-on-host glyph
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../ImportedWorktreesVisibilityLine.tsx | 13 ++-
.../sidebar/NewExternalWorktreesInboxLine.tsx | 13 ++-
.../sidebar/NoticeHostGlyph.test.tsx | 95 +++++++++++++++++++
.../components/sidebar/NoticeHostGlyph.tsx | 71 ++++++++++++++
...ree-list-groups-notice-host-labels.test.ts | 15 +--
.../worktree-list/grouping/group-sections.ts | 3 +-
.../worktree-list/grouping/host-labels.ts | 21 ++--
.../grouping/pinned-group-rows.ts | 3 +-
.../worktree-list/grouping/row-builders.ts | 13 ++-
.../worktree-list/grouping/row-types.ts | 2 +
.../worktree-list/rows/notice-rows.tsx | 2 +
src/renderer/src/i18n/locales/en.json | 5 +
12 files changed, 233 insertions(+), 23 deletions(-)
create mode 100644 src/renderer/src/components/sidebar/NoticeHostGlyph.test.tsx
create mode 100644 src/renderer/src/components/sidebar/NoticeHostGlyph.tsx
diff --git a/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx b/src/renderer/src/components/sidebar/ImportedWorktreesVisibilityLine.tsx
index 60dcff4883d..96967e8a5f0 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'
@@ -22,6 +24,7 @@ type ImportedWorktreesVisibilityLineProps = {
/** 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
@@ -78,6 +81,7 @@ export function groupWorktreesByParentPath(
export default function ImportedWorktreesVisibilityLine({
repoDisplayName,
hostContextLabel,
+ hostContextHostId,
hiddenWorktrees,
placement,
pending,
@@ -153,8 +157,13 @@ export default function ImportedWorktreesVisibilityLine({
{lineText}
{hostContextLabel && placement !== 'pinned-fallback' ? (
-
- {hostContextLabel}
+
+ {hostContextHostId ? (
+
+ ) : null}
+
+ {hostContextLabel}
+
) : null}
{onKeepHidden ? (
diff --git a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx
index 78a1999f93d..856ebe8bf11 100644
--- a/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.tsx
+++ b/src/renderer/src/components/sidebar/NewExternalWorktreesInboxLine.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 { translate } from '@/i18n/i18n'
type NewExternalWorktreesInboxLineProps = {
@@ -11,6 +13,7 @@ type NewExternalWorktreesInboxLineProps = {
/** 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
@@ -22,6 +25,7 @@ type NewExternalWorktreesInboxLineProps = {
export default function NewExternalWorktreesInboxLine({
repoDisplayName,
hostContextLabel,
+ hostContextHostId,
inboxCount,
pending,
error,
@@ -97,8 +101,13 @@ export default function NewExternalWorktreesInboxLine({
{countLabel}
{hostContextLabel ? (
-
- {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: ReactNode }) => <>{children}>,
+ TooltipContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+}))
+
+const roots: Root[] = []
+
+async function render(hostId: string, hostLabel = 'openclaw'): 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('draws nothing for a local host, matching the worktree card', async () => {
+ const container = await render('local', 'Local Mac')
+
+ expect(container.textContent).toBe('')
+ })
+
+ 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}}',
+ runtimeHostProject: 'Project on {{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..8712ae7024d
--- /dev/null
+++ b/src/renderer/src/components/sidebar/NoticeHostGlyph.tsx
@@ -0,0 +1,71 @@
+import React from 'react'
+import { Server, ServerOff } from 'lucide-react'
+
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { useAppStore } from '@/store'
+import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
+import { translate } from '@/i18n/i18n'
+
+type NoticeHostGlyphProps = {
+ hostId: ExecutionHostId
+ hostLabel: string
+}
+
+/**
+ * The host indicator for a discovery-notice row.
+ *
+ * Deliberately the same glyph and "Project on …" tooltip worktree cards use
+ * (worktree-card-header.tsx), so one project's rows read the same way wherever
+ * they appear. Local hosts get none, matching the cards.
+ */
+export default function NoticeHostGlyph({
+ hostId,
+ hostLabel
+}: 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 || host.kind === 'local') {
+ 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 }
+ )
+ : translate(
+ 'auto.components.sidebar.NoticeHostGlyph.runtimeHostProject',
+ 'Project on {{hostName}}',
+ { hostName: hostLabel }
+ )
+
+ return (
+
+
+
+ {isDisconnected ? (
+
+ ) : (
+
+ )}
+
+
+
+ {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
index c1e199a1b04..8bbf99f9260 100644
--- 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
@@ -126,10 +126,13 @@ function noticeRows(args: {
function summarize(
rows: Extract[]
-): { repoId: string; label: string | undefined; count: number }[] {
+): { 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
}))
}
@@ -154,8 +157,8 @@ describe('discovery notice rows on a multi-host project', () => {
})
)
).toEqual([
- { repoId: sshTwin.id, label: 'openclaw', count: 61 },
- { repoId: envTwin.id, label: 'openclaw', count: 134 }
+ { repoId: sshTwin.id, label: 'openclaw', hostId: SSH_HOST_ID, count: 61 },
+ { repoId: envTwin.id, label: 'openclaw', hostId: ENV_HOST_ID, count: 134 }
])
})
@@ -163,7 +166,7 @@ describe('discovery notice rows on a multi-host project', () => {
// 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', count: 134 }
+ { repoId: envTwin.id, label: 'openclaw', hostId: ENV_HOST_ID, count: 134 }
])
})
@@ -171,10 +174,10 @@ describe('discovery notice rows on a multi-host project', () => {
// 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', count: 61 }
+ { repoId: sshTwin.id, label: 'openclaw', hostId: SSH_HOST_ID, count: 61 }
])
expect(summarize(noticeRows({ eligibleRepoIds: [envTwin.id] }))).toEqual([
- { repoId: envTwin.id, label: 'openclaw', count: 134 }
+ { repoId: envTwin.id, label: 'openclaw', hostId: ENV_HOST_ID, count: 134 }
])
})
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 a556291929a..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,7 +45,7 @@ export type SectionAppendContext = {
newExternalWorktreesInboxByRepo: ReadonlyMap
pendingByRepo: ReadonlyMap
mixedWorktreeHostContextLabels: Map | undefined
- noticeHostContextLabelByRepoId: Map | undefined
+ noticeHostContextLabelByRepoId: Map | undefined
lineageById: Record
worktreeMap: Map
nestLineage: boolean
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 bed014af223..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
@@ -70,13 +70,20 @@ export function getMixedHostContextLabels(
* 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 {
+): Map | undefined {
const eligible = new Set(noticeRepoIds)
if (eligible.size === 0) {
return undefined
@@ -85,7 +92,7 @@ export function getNoticeHostContextLabels(
// 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 labelsByRepoId = new Map()
const projectKeyByRepoId = new Map()
for (const repoId of allRepoIds) {
const label = getRepoHostLabel(repoId, repoMap, projectIndex, hostLabelById)
@@ -99,16 +106,16 @@ export function getNoticeHostContextLabels(
hostIds.add(hostId)
hostIdsForProject.set(projectKey, hostIds)
}
- if (eligible.has(repoId)) {
- labelsByRepoId.set(repoId, label)
+ if (eligible.has(repoId) && hostId) {
+ labelsByRepoId.set(repoId, { label, hostId: hostId as ExecutionHostId })
projectKeyByRepoId.set(repoId, projectKey)
}
}
- const mixed = new Map()
- for (const [repoId, label] of labelsByRepoId) {
+ 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, label)
+ mixed.set(repoId, context)
}
}
return mixed.size > 0 ? mixed : undefined
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 a0755737b4f..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'
/**
@@ -26,7 +27,7 @@ export function emitPinnedGroup(
worktreeMap: Map,
nestLineage: boolean,
cyclicLineageIds: ReadonlySet,
- noticeHostContextLabelByRepoId?: ReadonlyMap
+ noticeHostContextLabelByRepoId?: ReadonlyMap
): void {
if (pinnedSectionWorktrees.length === 0) {
return
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 479a70d4c64..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,
@@ -33,7 +34,7 @@ export function buildPendingCreationRow(
export function buildImportedWorktreesCardRow(
candidate: ImportedWorktreesCardCandidate,
placement: ImportedWorktreesCardRow['placement'],
- hostContextLabel?: string
+ hostContext?: NoticeHostContext
): ImportedWorktreesCardRow {
return {
type: 'imported-worktrees-card',
@@ -41,20 +42,24 @@ export function buildImportedWorktreesCardRow(
repo: candidate.repo,
hiddenWorktrees: candidate.hiddenWorktrees,
placement,
- ...(hostContextLabel ? { hostContextLabel } : {})
+ ...(hostContext
+ ? { hostContextLabel: hostContext.label, hostContextHostId: hostContext.hostId }
+ : {})
}
}
export function buildNewExternalWorktreesInboxRow(
candidate: NewExternalWorktreesInboxCandidate,
- hostContextLabel?: string
+ hostContext?: NoticeHostContext
): NewExternalWorktreesInboxRow {
return {
type: 'new-external-worktrees-inbox',
key: `new-external-worktrees-inbox:${candidate.repo.id}`,
repo: candidate.repo,
inboxWorktrees: candidate.inboxWorktrees,
- ...(hostContextLabel ? { hostContextLabel } : {})
+ ...(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 416e263d180..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
@@ -59,6 +59,7 @@ export type ImportedWorktreesCardRow = {
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 = {
@@ -73,6 +74,7 @@ export type NewExternalWorktreesInboxRow = {
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 7276b7058b8..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
@@ -63,6 +63,7 @@ export function renderImportedWorktreesVirtualRow(args: {