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