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