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: {