Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 0 additions & 41 deletions apps/app/src/components/machines/MachineStatusDot.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
import type { ReactNode } from "react";
import { Icon } from "@bb/shared-ui/icon";
import { cn } from "@bb/shared-ui/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@bb/shared-ui/tooltip";

/**
* Connection-status dot for a machine (host): filled success dot when the
Expand All @@ -31,36 +23,3 @@ export function MachineStatusDot({
/>
);
}

/** Accessible connection state for surfaces where a dot alone is ambiguous. */
export function MachineStatusIcon({
connected,
tooltip,
className,
}: {
connected: boolean;
tooltip?: ReactNode;
className?: string;
}) {
const label = connected ? "Online" : "Offline";
const icon = connected ? "Cloud" : "CloudOff";
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
aria-label={label}
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center text-muted-foreground",
className,
)}
>
<Icon aria-hidden name={icon} className="size-4" />
</span>
</TooltipTrigger>
<TooltipContent>{tooltip ?? label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
73 changes: 25 additions & 48 deletions apps/app/src/components/settings/MachinesSettingsSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ afterEach(() => {
});

describe("MachinesSettingsSection", () => {
it("renders machine status, project, and permission metadata as accessible icons", async () => {
it("renders machine status, project, and permission metadata as visible text", async () => {
vi.mocked(sdk.system.config).mockResolvedValue(systemConfig());
vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]);
stubSidebarBootstrapFetch();
Expand All @@ -163,27 +163,17 @@ describe("MachinesSettingsSection", () => {
expect(screen.getByText("dev-vm")).toBeDefined();
expect(screen.getByText("this machine")).toBeDefined();
expect(screen.getByText("primary")).toBeDefined();
await waitFor(() => {
expect(screen.getByRole("img", { name: "Online" })).toBeDefined();
});
expect(
screen
.getByRole("img", { name: "Online" })
.querySelector('[data-icon="Cloud"]'),
).not.toBeNull();
expect(screen.getByText("Online")).toBeDefined();
expect(screen.getByText(/^Offline · last seen/u)).toBeDefined();
expect(screen.getByText("2 projects")).toBeDefined();
expect(screen.getByText("1 project")).toBeDefined();
expect(screen.getAllByText("Full Access")).toHaveLength(2);
expect(screen.getByText("macOS")).toBeDefined();
expect(
screen
.getByRole("img", { name: "Offline" })
.querySelector('[data-icon="CloudOff"]'),
).not.toBeNull();
const primaryProjects = screen.getByRole("img", { name: "2 projects" });
expect(
primaryProjects.querySelector('[data-icon="FolderGit"]'),
).not.toBeNull();
expect(screen.getByRole("img", { name: "1 project" })).toBeDefined();
expect(screen.getAllByRole("img", { name: "Full Access" })).toHaveLength(2);
expect(screen.getByText("macOS")).toBeDefined();
expect(screen.queryByText(/Online ·/u)).toBeNull();
.getByRole("link", { name: "Open MacBook Pro" })
.querySelector("[data-icon]"),
).toBeNull();
});

it("distinguishes the client-local daemon from the primary machine", async () => {
Expand Down Expand Up @@ -255,11 +245,11 @@ describe("MachinesSettingsSection", () => {

renderSection();

expect(
await screen.findByRole("img", {
name: `Needs update · daemon protocol ${HOST_DAEMON_PROTOCOL_VERSION - 1} · server protocol ${HOST_DAEMON_PROTOCOL_VERSION}`,
}),
).toBeDefined();
const updateStatus = await screen.findByText(
`Needs update · daemon protocol ${HOST_DAEMON_PROTOCOL_VERSION - 1} · server protocol ${HOST_DAEMON_PROTOCOL_VERSION}`,
);
expect(updateStatus.className).toContain("min-w-0");
expect(updateStatus.className).not.toContain("shrink-0");
// The action lives in the row menu so the rows keep one shape.
await openHostMenu("dev-vm");
const renameItem = await screen.findByRole("menuitem", { name: "Rename" });
Expand Down Expand Up @@ -309,23 +299,22 @@ describe("MachinesSettingsSection", () => {
});
});

it("uses a compact accessible Add machine action", async () => {
it("uses a labeled Add a machine action", async () => {
vi.mocked(sdk.system.config).mockResolvedValue(systemConfig());
vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]);
stubSidebarBootstrapFetch();

renderSection();

const addMachine = await screen.findByRole("button", {
name: "Add machine",
name: "Add a machine",
});
expect(addMachine.textContent).toBe("");
expect(addMachine.className).toContain("size-7");
expect(addMachine.textContent).toBe("Add a machine");
expect(addMachine.querySelector('[data-icon="Plus"]')).not.toBeNull();
fireEvent.pointerMove(addMachine);
expect((await screen.findByRole("tooltip")).textContent).toBe(
"Add machine",
);
const action = addMachine.parentElement;
expect(action?.className).toContain("self-start");
expect(action?.parentElement?.className).toContain("flex-col");
expect(action?.parentElement?.className).toContain("sm:flex-row");
fireEvent.click(addMachine);
expect(
await screen.findByRole("heading", { name: "Add a machine" }),
Expand Down Expand Up @@ -359,7 +348,7 @@ describe("MachinesSettingsSection", () => {
});
});

it("keeps permission metadata left-aligned and reserves a hover caret", async () => {
it("shows permission metadata as text and reserves a hover caret", async () => {
vi.mocked(sdk.system.config).mockResolvedValue(systemConfig());
vi.mocked(sdk.hosts.list).mockResolvedValue([
primaryHost,
Expand All @@ -369,14 +358,8 @@ describe("MachinesSettingsSection", () => {

renderSection();

expect(
await screen.findByRole("img", {
name: "Accept Edits",
}),
).toBeDefined();
expect(screen.getByRole("img", { name: "Full Access" })).toBeDefined();
expect(screen.queryByText("Accept Edits")).toBeNull();
expect(screen.queryByText("Full Access")).toBeNull();
expect(await screen.findByText("Accept Edits")).toBeDefined();
expect(screen.getByText("Full Access")).toBeDefined();
// The control itself lives on the machine page.
expect(
screen.queryByRole("button", { name: /Permission limit for/ }),
Expand All @@ -390,12 +373,6 @@ describe("MachinesSettingsSection", () => {
expect(row?.className).toContain("focus-within:bg-state-hover");
expect(row?.className).toContain("px-2");
expect(row?.className).toContain("py-2");
const permission = screen.getByRole("img", { name: "Accept Edits" });
expect(permission.querySelector('[data-icon="FolderEdit"]')).not.toBeNull();
fireEvent.pointerMove(permission);
expect((await screen.findByRole("tooltip")).textContent).toBe(
"Accept Edits",
);
const caret = row?.querySelector('[data-icon="ChevronRight"]');
expect(caret?.classList.contains("opacity-0")).toBe(true);
expect(caret?.classList.contains("size-3.5")).toBe(true);
Expand Down
122 changes: 35 additions & 87 deletions apps/app/src/components/settings/MachinesSettingsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState, type ReactNode } from "react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Host, PermissionMode } from "@bb/domain";
import { RETRY_ACTION_ICON } from "@bb/domain/update-state";
Expand All @@ -16,7 +16,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@bb/shared-ui/dropdown-menu";
import { Icon, type IconName } from "@bb/shared-ui/icon";
import { Icon } from "@bb/shared-ui/icon";
import { cn } from "@bb/shared-ui/lib/utils";
import { ResourceRowDetailChevron } from "@bb/shared-ui/resource-list";
import {
Expand All @@ -28,7 +28,7 @@ import {
import { AddMachineDialog } from "@/components/dialogs/AddMachineDialog";
import { ConfirmDeleteDialog } from "@/components/dialogs/ConfirmDeleteDialog";
import { appToast } from "@/components/ui/app-toast";
import { MachineStatusIcon } from "@/components/machines/MachineStatusDot";
import { MachineStatusDot } from "@/components/machines/MachineStatusDot";
import { MachineRenameDialog } from "@/components/settings/MachineRenameDialog";
import {
SettingsBadge,
Expand All @@ -45,7 +45,6 @@ import { useHosts } from "@/hooks/queries/host-queries";
import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query";
import { useSystemConfig } from "@/hooks/queries/system-queries";
import { useHostDaemon } from "@/hooks/useHostDaemon";
import { PersistentHostIconName } from "@/lib/host-display";
import { getSettingsMachineRoutePath } from "@/lib/route-paths";
import { PERMISSION_MODE_OPTIONS } from "@/lib/permission-mode-options";
import { getMutationErrorMessage } from "@/lib/mutation-errors";
Expand Down Expand Up @@ -76,39 +75,6 @@ const PLATFORM_LABELS: Record<HostPlatform, string | null> = {
unknown: null,
};

function MachineMetadataIcon({
icon,
label,
children,
className,
}: {
icon: IconName;
label: string;
children?: ReactNode;
className?: string;
}) {
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<span
role="img"
aria-label={label}
className={cn(
"inline-flex shrink-0 items-center gap-1 text-subtle-foreground/75",
className,
)}
>
<Icon aria-hidden name={icon} className="size-3.5" />
{children}
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

interface MachineRowProps {
host: Host;
isPrimary: boolean;
Expand Down Expand Up @@ -138,10 +104,12 @@ function MachineRow({
}: MachineRowProps) {
const permission = PERMISSION_MODE_PRESENTATION[host.maxPermissionMode];
const projectLabel = `${projectCount} ${projectCount === 1 ? "project" : "projects"}`;
const offlineTooltip =
host.lastSeenAt === null
? "Offline"
: `Offline · last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`;
const connectionLabel =
host.status === "connected"
? "Online"
: host.lastSeenAt === null
? "Offline"
: `Offline · last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`;
const updateStatus = formatHostUpdateStatus(host);
const removeItem = (
<DropdownMenuItem
Expand Down Expand Up @@ -173,12 +141,8 @@ function MachineRow({
<Link
to={getSettingsMachineRoutePath(host.id)}
aria-label={`Open ${host.name}`}
className="flex min-w-0 flex-1 items-center gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex min-w-0 flex-1 items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Icon
name={PersistentHostIconName}
className="size-4 shrink-0 text-muted-foreground"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate text-sm font-medium text-foreground">
Expand All @@ -189,35 +153,27 @@ function MachineRow({
) : null}
{showPrimaryBadge ? <SettingsBadge>primary</SettingsBadge> : null}
</div>
<div className="flex min-w-0 items-center gap-2 text-xs text-subtle-foreground/75">
<MachineStatusIcon
connected={host.status === "connected"}
tooltip={
host.status === "connected" ? "Online" : offlineTooltip
}
className="size-3.5 [&_[data-icon]]:size-3.5"
/>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-subtle-foreground/75">
<span className="inline-flex shrink-0 items-center gap-1.5">
<MachineStatusDot connected={host.status === "connected"} />
{connectionLabel}
</span>
{platformLabel === null ? null : (
<span className="truncate">{platformLabel}</span>
)}
<MachineMetadataIcon icon="FolderGit" label={projectLabel}>
<span aria-hidden>{projectCount}</span>
</MachineMetadataIcon>
<MachineMetadataIcon
icon={permission.iconName}
label={permission.label}
className={
permission.tone === "warning"
? "text-warning-text"
: undefined
}
/>
<span className="shrink-0">{projectLabel}</span>
<span
className={cn(
"shrink-0",
permission.tone === "warning" && "text-warning-text",
)}
>
{permission.label}
</span>
{updateStatus === null ? null : (
<MachineMetadataIcon
icon="AlertTriangle"
label={updateStatus}
className="text-warning-text"
/>
<span className="min-w-0 text-warning-text">
{updateStatus}
</span>
)}
</div>
</div>
Expand Down Expand Up @@ -315,22 +271,14 @@ export function MachinesSettingsSection() {
title="Machines"
description={MACHINES_SECTION_DESCRIPTION}
action={
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-7 text-muted-foreground hover:text-foreground"
aria-label="Add machine"
onClick={() => setAddDialogOpen(true)}
>
<Icon name="Plus" className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Add machine</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
size="sm"
variant="outline"
onClick={() => setAddDialogOpen(true)}
>
<Icon name="Plus" className="size-3.5" />
Add a machine
</Button>
}
>
{hosts === undefined ? (
Expand Down
6 changes: 3 additions & 3 deletions apps/app/src/components/ui/settings-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export function SettingsSection({
<section className="space-y-3">
<div
className={cn(
"flex justify-between gap-4",
description ? "items-start" : "items-center",
"flex flex-col gap-3 sm:flex-row sm:justify-between sm:gap-4",
description ? "sm:items-start" : "sm:items-center",
)}
>
<div className="min-w-0">
Expand All @@ -44,7 +44,7 @@ export function SettingsSection({
</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
{action ? <div className="shrink-0 self-start">{action}</div> : null}
</div>
<div
className={cn(
Expand Down
Loading