From e7da47e37bcbad85fae5f8951a1c5a32078968be Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 21 Sep 2026 17:21:25 +0800 Subject: [PATCH] fix(web-ui): draw headless host groups with the server mark The footer's attached-device groups drew Lucide's outline server for a group of headless hosts, in a row where the desktop group beside it already draws the device mark, and where the device list draws the product's own server mark for every one of those hosts. One surface held two drawings of the same idea, and the outline one read as the odd, hollow glyph next to a solid mark. The group now draws the mark the list draws. A group is a class rather than one device, so it carries only the fact its mark resolves from: the kind. The name, system and host kind a device row would answer with do not exist at this level and the mark does not read them. With no caller left for the outline case, the LUCIDE Server case and its import go with it, so the footer has one server drawing instead of two. The other Server glyphs in the shell are not devices and stay: an SSH host is a transport target (the remote-SSH menu item, a remote workspace row, the dispatch picker's SSH connections and target), and the ACP registry rows are agent servers. Each of those is a remote machine, not an OpenBitFun host in the account directory, and each sits in a row of Lucide glyphs of its own. Verified by a test that reads the rendered path: the group and the list row for the same host kind now carry one drawing. The test fails when the routing is reverted, which is how the guard was checked. Co-authored-by: bitfun-ai <318544290+bitfun-ai@users.noreply.github.com> --- .../components/DeviceStatusControl.test.tsx | 29 +++++++++++++++++++ .../components/DeviceStatusControl.tsx | 28 ++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx index 887d0c101d..b56c0a6b3f 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx @@ -391,6 +391,35 @@ describe('device status card', () => { expect(attachedDesktop()?.querySelector('svg')).not.toBeNull(); }); + it('draws a group of headless hosts with the mark the list draws for them', () => { + const footer = () => element('nav-footer-device-status'); + const headlessGroup = () => footer() + .querySelector('[data-openbitfun-device-kind="execution-host"]'); + + // A dispatch job running on another host is what puts that group in the + // footer, and the group names a class rather than one device: it has no row + // to read a system from. + state.overview = overview({ + localDeviceName: 'Workstation', + localDeviceOs: 'macOS', + dispatchJobs: [{ + id: 'job-1', + state: 'running', + target: { kind: 'device', id: 'peer-2', name: 'Build host' }, + }], + }); + render(); + + const drawnPath = (scope: Element | null | undefined) => ( + scope?.querySelector('svg[data-system="server"] path')?.getAttribute('d') + ); + const listRow = element('nav-device-status-connected-devices') + .querySelector('[data-openbitfun-device-kind="execution-host"]'); + // One drawing, whatever slot it lands in: the group is not a second glyph. + expect(drawnPath(headlessGroup())).not.toBeNull(); + expect(drawnPath(headlessGroup())).toBe(drawnPath(listRow)); + }); + it('marks a device name with the system that device runs', () => { state.overview = overview({ localDeviceName: 'Workstation', diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index e64d0b3ede..110f385954 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -1,7 +1,7 @@ import { useDeviceDirectory, resolveDeviceName, isDeviceControllable, deviceClientVersion } from '@/infrastructure/account/deviceDirectory'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { subscribeOverlayInteraction, createOverlayPortal, OverflowText, Button, Card, CardBody, CardFooter, CardHeader, Icon, IconButton, ScrollArea, type IconSize } from '@openbitfun/ui'; -import { ChevronLeft, ChevronRight, MessageCircle, Monitor, Server, Smartphone, Undo2 } from 'lucide-react'; +import { ChevronLeft, ChevronRight, MessageCircle, Monitor, Smartphone, Undo2 } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; @@ -43,6 +43,24 @@ function chatAppBrandFromIdentity(identity: string | null | undefined): ChatAppB return null; } +/** + * The class of headless hosts the footer names as one group. A group is not a + * device, so it carries the facts its mark is resolved from and nothing else: + * the kind already decides that mark, and the name, system and host kind a row + * would answer with do not exist at this level. + */ +const HEADLESS_HOST_CLASS: React.ComponentProps['device'] = { + kind: 'execution-host', + name: '', + os: null, + hostKind: null, +}; + +/** + * The kinds that are not a system: a phone, a chat app, and the neutral monitor + * for a kind this build cannot place. A desktop and a headless host are the + * machines the device marks draw, so they never reach this switch. + */ function DeviceIcon({ identity, kind, @@ -55,8 +73,6 @@ function DeviceIcon({ switch (kind) { case 'mobile': return ; - case 'execution-host': - return ; case 'message-app': { const chatApp = chatAppBrandFromIdentity(identity); if (chatApp) { @@ -385,6 +401,12 @@ const DeviceStatusControl: React.FC = ({ // the group names the same device the list does, rather than a // second generic monitor. + ) : group.kind === 'execution-host' ? ( + // A group of headless hosts is a class, not one device, so there + // is no row to read a system from: it draws the mark the list + // draws for each of them, the way the desktop group does, rather + // than a second server glyph of its own. + ) : (