diff --git a/packages/app/e2e/acp-provider-catalog.spec.ts b/packages/app/e2e/acp-provider-catalog.spec.ts index 55b24add5..1b1439d37 100644 --- a/packages/app/e2e/acp-provider-catalog.spec.ts +++ b/packages/app/e2e/acp-provider-catalog.spec.ts @@ -6,6 +6,7 @@ import { installAcpCatalogProvider, openAddProviderModal, openSettingsHost, + openSettingsHostSection, } from "./helpers/settings"; const ACP_PROVIDER = { @@ -18,6 +19,8 @@ test.describe("ACP provider catalog", () => { await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, getServerId()); + // Providers moved to their own host section; add-provider lives there now. + await openSettingsHostSection(page, getServerId(), "providers"); await openAddProviderModal(page); await installAcpCatalogProvider(page, ACP_PROVIDER.name); diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index 9171542bd..9fac2a086 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { expect, type Page } from "@playwright/test"; import { openSettings } from "./app"; import { getE2EDaemonPort } from "./daemon-port"; -import { openSettingsHost } from "./settings"; +import { openSettingsHost, openSettingsHostSection } from "./settings"; interface DaemonApiStatus { version: string; @@ -208,6 +208,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi export async function openDesktopSettings(page: Page, serverId: string): Promise { await openSettings(page); await openSettingsHost(page, serverId); + // The daemon-lifecycle card moved to the Daemon section in the flat-settings + // layout; navigate there before asserting it. + await openSettingsHostSection(page, serverId, "daemon"); await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({ timeout: 15_000, }); diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index adc13eb3c..0b6ff00b6 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -1,4 +1,5 @@ import { expect, type Page } from "@playwright/test"; +import { TEST_HOST_LABEL } from "./daemon-registry"; import { escapeRegex } from "./regex"; import { getServerId } from "./server-id"; @@ -13,6 +14,8 @@ const SECTION_LABELS = { export type SettingsSection = keyof typeof SECTION_LABELS | "projects"; +type HostSection = "connections" | "orchestration" | "providers" | "daemon"; + export async function openSettingsSection(page: Page, section: SettingsSection): Promise { const sidebar = page.getByTestId("settings-sidebar"); await expect(sidebar).toBeVisible(); @@ -28,8 +31,22 @@ export async function openSettingsSection(page: Page, section: SettingsSection): } export async function openSettingsHost(page: Page, serverId: string): Promise { - await page.getByTestId(`settings-host-entry-${serverId}`).click(); - await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); + // Host sections are now flat top-level rows under the Host group. Navigate by + // clicking the Connections section row; the picker only matters when >1 host. + await page.getByTestId("settings-host-section-connections").click(); + await expectHostSettingsUrl(page, serverId); + await expect(page.getByTestId("host-page-connections-card")).toBeVisible(); +} + +export async function openSettingsHostSection( + page: Page, + serverId: string, + section: HostSection, +): Promise { + await page.getByTestId(`settings-host-section-${section}`).click(); + await expect(page).toHaveURL( + new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`), + ); } export async function expectSettingsHeader(page: Page, title: string): Promise { @@ -37,6 +54,9 @@ export async function expectSettingsHeader(page: Page, title: string): Promise { + // "Add host" is now an item inside the host picker (a Combobox); open the + // picker first, then pick it. The picker renders whenever a host exists. + await page.getByTestId("settings-host-picker").click(); await page.getByTestId("settings-add-host").click(); await expect(page.getByText("Add connection", { exact: true })).toBeVisible(); } @@ -68,7 +88,7 @@ export async function expectCompactSettingsList(page: Page): Promise { await expect(page.getByTestId("settings-sidebar")).toBeVisible(); await expect(page.getByText("Theme", { exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0); - await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0); + await expect(page.getByTestId("host-page-connections-card")).toHaveCount(0); } export async function expectSettingsSidebarVisible(page: Page): Promise { @@ -105,7 +125,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise { export async function expectHostSettingsUrl(page: Page, serverId: string): Promise { await expect(page).toHaveURL( - new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`), + new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`), ); } @@ -181,7 +201,11 @@ export async function expectHostLabelEditMode(page: Page, expectedLabel: string) export async function expectHostConnectionsCard(page: Page, port: string): Promise { const card = page.getByTestId("host-page-connections-card"); await expect(card).toBeVisible(); - await expect(page.getByText("Connections", { exact: true })).toBeVisible(); + // "Connections" appears three times on this page: the sidebar section row, the + // detail header title, and the SettingsSection heading above the card. Match + // the first to keep the heading assertion without tripping Playwright strict + // mode. + await expect(page.getByText("Connections", { exact: true }).first()).toBeVisible(); await expect( card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)), ).toBeVisible(); @@ -193,14 +217,29 @@ export async function expectHostInjectMcpCard(page: Page): Promise { await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible(); } -export async function expectHostActionCards(page: Page): Promise { +export async function openHostSection( + page: Page, + serverId: string, + section: HostSection, +): Promise { + await openSettingsHostSection(page, serverId, section); +} + +export async function expectHostActionCards(page: Page, serverId: string): Promise { + // Restart + remove cards live on the Daemon section; providers moved to its + // own Providers section (asserted via expectHostProvidersCard). + await openSettingsHostSection(page, serverId, "daemon"); await expect(page.getByTestId("host-page-restart-card")).toBeVisible(); await expect(page.getByTestId("host-page-restart-button")).toBeVisible(); - await expect(page.getByTestId("host-page-providers-card")).toBeVisible(); await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible(); } +export async function expectHostProvidersCard(page: Page, serverId: string): Promise { + await openSettingsHostSection(page, serverId, "providers"); + await expect(page.getByTestId("host-page-providers-card")).toBeVisible(); +} + export async function serveJson(page: Page, url: string, body: unknown): Promise { await page.route(url, async (route) => { await route.fulfill({ @@ -244,27 +283,35 @@ export async function expectHostNoLocalOnlyRows(page: Page): Promise { export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise { const sidebar = page.getByTestId("settings-sidebar"); await expect(sidebar).toBeVisible(); - await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0); - await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0); - await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0); - await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0); + + // App group rows remain top-level. await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible(); await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible(); await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible(); + + // Host group rows are now flat top-level sections (no drill-in). + await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible(); + await expect(sidebar.getByTestId("settings-host-section-orchestration")).toBeVisible(); + await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible(); + await expect(sidebar.getByTestId("settings-host-section-daemon")).toBeVisible(); + + // The old per-host entry rows are replaced by the host picker. + await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0); } -export async function expectHostPageVisible(page: Page, serverId: string): Promise { - await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); +export async function expectHostPageVisible(page: Page, _serverId: string): Promise { + await expect(page.getByTestId("host-page-connections-card")).toBeVisible(); } -export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise { +export async function expectLocalHostEntryFirst(page: Page, _serverId: string): Promise { const sidebar = page.getByTestId("settings-sidebar"); await expect(sidebar).toBeVisible({ timeout: 15_000 }); - await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute( - "data-testid", - `settings-host-entry-${serverId}`, - ); - const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`); - await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible(); - await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible(); + + // Single-host fixture: the picker is a non-interactive chip (no dropdown to + // open) that surfaces the local host by its label. The "Local" marker only + // appears on dropdown rows in the multi-host case, which this fixture does not + // exercise. + const picker = sidebar.getByTestId("settings-host-picker"); + await expect(picker).toBeVisible(); + await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible(); } diff --git a/packages/app/e2e/settings-host-page.spec.ts b/packages/app/e2e/settings-host-page.spec.ts index 76c9ab5d2..940654e7a 100644 --- a/packages/app/e2e/settings-host-page.spec.ts +++ b/packages/app/e2e/settings-host-page.spec.ts @@ -6,12 +6,14 @@ import { getServerId } from "./helpers/server-id"; import { expectSettingsHeader, openSettingsHost, + openHostSection, expectHostLabelDisplayed, clickEditHostLabel, expectHostLabelEditMode, expectHostConnectionsCard, expectHostInjectMcpCard, expectHostActionCards, + expectHostProvidersCard, expectHostNoLocalOnlyRows, expectRetiredSidebarSectionsAbsent, expectHostPageVisible, @@ -19,9 +21,7 @@ import { } from "./helpers/settings"; test.describe("Settings host page", () => { - test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({ - page, - }) => { + test("connections section shows the seeded connection endpoint", async ({ page }) => { const serverId = getServerId(); const port = getE2EDaemonPort(); @@ -29,11 +29,44 @@ test.describe("Settings host page", () => { await openSettings(page); await openSettingsHost(page, serverId); - await expectSettingsHeader(page, TEST_HOST_LABEL); - await expectHostLabelDisplayed(page); + await expectSettingsHeader(page, "Connections"); await expectHostConnectionsCard(page, port); + }); + + test("orchestration section shows the inject MCP toggle", async ({ page }) => { + const serverId = getServerId(); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, serverId); + + await openHostSection(page, serverId, "orchestration"); + await expectSettingsHeader(page, "Orchestration"); await expectHostInjectMcpCard(page); - await expectHostActionCards(page); + }); + + test("providers section shows the providers card", async ({ page }) => { + const serverId = getServerId(); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, serverId); + + await expectHostProvidersCard(page, serverId); + await expectSettingsHeader(page, "Providers"); + }); + + test("daemon section shows the host label and restart/remove action cards", async ({ page }) => { + const serverId = getServerId(); + + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, serverId); + + await openHostSection(page, serverId, "daemon"); + await expectSettingsHeader(page, "Daemon"); + await expectHostLabelDisplayed(page); + await expectHostActionCards(page, serverId); }); test("clicking the label pencil reveals the inline editor", async ({ page }) => { @@ -42,13 +75,14 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); + await openHostSection(page, serverId, "daemon"); await expectHostLabelDisplayed(page); await clickEditHostLabel(page); await expectHostLabelEditMode(page, TEST_HOST_LABEL); }); - test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ + test("daemon section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ page, }) => { const serverId = getServerId(); @@ -56,19 +90,20 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); + await openHostSection(page, serverId, "daemon"); // TODO: add local-daemon fixture for positive Pair/Daemon coverage. await expectHostNoLocalOnlyRows(page); }); - test("settings sidebar does not expose retired top-level sections", async ({ page }) => { + test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => { await gotoAppShell(page); await openSettings(page); await expectRetiredSidebarSectionsAbsent(page); }); - test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({ + test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({ page, }) => { const serverId = getServerId(); @@ -77,9 +112,10 @@ test.describe("Settings host page", () => { await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`); await expectHostPageVisible(page, serverId); - await expectSettingsHeader(page, TEST_HOST_LABEL); + await expectSettingsHeader(page, "Connections"); + await openHostSection(page, serverId, "daemon"); await expectHostLabelDisplayed(page); - await expectHostActionCards(page); + await expectHostActionCards(page, serverId); }); test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => { diff --git a/packages/app/e2e/settings-navigation.spec.ts b/packages/app/e2e/settings-navigation.spec.ts index 899ae85a0..de47e8d46 100644 --- a/packages/app/e2e/settings-navigation.spec.ts +++ b/packages/app/e2e/settings-navigation.spec.ts @@ -45,7 +45,7 @@ test.describe("Settings sidebar navigation", () => { await expectGeneralContent(page); }); - test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => { + test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => { await gotoAppShell(page); await verifyLegacyHostSettingsRedirect(page); }); @@ -132,7 +132,9 @@ test.describe("Settings — compact master-detail", () => { await expectSettingsBackButton(page); }); - test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => { + test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({ + page, + }) => { await gotoAppShell(page); await openCompactSettings(page); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 37fb673e5..3affc37ee 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -867,7 +867,8 @@ function RootStack() { - + + ); } diff --git a/packages/app/src/app/settings/hosts/[serverId].tsx b/packages/app/src/app/settings/hosts/[serverId].tsx deleted file mode 100644 index 9a3058c45..000000000 --- a/packages/app/src/app/settings/hosts/[serverId].tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useLocalSearchParams } from "expo-router"; -import { useMemo } from "react"; -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import SettingsScreen from "@/screens/settings-screen"; - -export default function SettingsHostRoute() { - const params = useLocalSearchParams<{ serverId?: string }>(); - const serverId = typeof params.serverId === "string" ? params.serverId.trim() : ""; - const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]); - - return ( - - - - ); -} diff --git a/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx b/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx new file mode 100644 index 000000000..7ca4283ce --- /dev/null +++ b/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx @@ -0,0 +1,19 @@ +import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import SettingsScreen from "@/screens/settings-screen"; +import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes"; + +export default function SettingsHostSectionRoute() { + const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId.trim() : ""; + const rawSection = typeof params.hostSection === "string" ? params.hostSection : ""; + const section: HostSectionSlug = isHostSectionSlug(rawSection) ? rawSection : "connections"; + const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]); + + return ( + + + + ); +} diff --git a/packages/app/src/app/settings/hosts/[serverId]/index.tsx b/packages/app/src/app/settings/hosts/[serverId]/index.tsx new file mode 100644 index 000000000..0ea254fa5 --- /dev/null +++ b/packages/app/src/app/settings/hosts/[serverId]/index.tsx @@ -0,0 +1,13 @@ +import { Redirect, useLocalSearchParams } from "expo-router"; +import { buildSettingsHostSectionRoute, buildSettingsRoute } from "@/utils/host-routes"; + +export default function SettingsHostIndexRoute() { + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId.trim() : ""; + + if (!serverId) { + return ; + } + + return ; +} diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 72f33235b..966cccdfe 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -17,7 +17,7 @@ import { import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; -import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes"; +import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useOpenProject } from "@/hooks/use-open-project"; @@ -62,7 +62,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { ); const handleOpenProviders = useCallback(() => { - router.push(buildSettingsHostRoute(serverId)); + router.push(buildSettingsHostSectionRoute(serverId, "providers")); }, [router, serverId]); return ( diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 21ab81bb3..d17a31e0b 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1,5 +1,13 @@ -import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import type { ComponentType, ReactNode } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { ComponentType, ReactElement, ReactNode } from "react"; import { Alert, Pressable, @@ -22,6 +30,9 @@ import { ChevronDown, Settings, Server, + Network, + Workflow, + Boxes, Keyboard, Stethoscope, Info, @@ -72,6 +83,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { isElectronRuntime } from "@/desktop/host"; @@ -81,7 +93,12 @@ import { resolveAppVersion } from "@/utils/app-version"; import { settingsStyles } from "@/styles/settings"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; -import { HostPage, HostRenameButton } from "@/screens/settings/host-page"; +import { + HostConnectionsPage, + HostDaemonPage, + HostOrchestrationPage, + HostProvidersPage, +} from "@/screens/settings/host-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -90,8 +107,9 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { buildHostOpenProjectRoute, buildProjectsSettingsRoute, - buildSettingsHostRoute, + buildSettingsHostSectionRoute, buildSettingsSectionRoute, + type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store"; @@ -103,7 +121,7 @@ import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-st export type SettingsView = | { kind: "root" } | { kind: "section"; section: SettingsSectionSlug } - | { kind: "host"; serverId: string } + | { kind: "host"; serverId: string; section: HostSectionSlug } | { kind: "projects" } | { kind: "project"; projectKey: string }; @@ -123,6 +141,19 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ { id: "about", label: "About", icon: Info }, ]; +interface HostSectionItem { + id: HostSectionSlug; + label: string; + icon: ComponentType<{ size: number; color: string }>; +} + +const HOST_SECTION_ITEMS: HostSectionItem[] = [ + { id: "connections", label: "Connections", icon: Network }, + { id: "orchestration", label: "Orchestration", icon: Workflow }, + { id: "providers", label: "Providers", icon: Boxes }, + { id: "daemon", label: "Daemon", icon: Server }, +]; + // --------------------------------------------------------------------------- // Theme helpers (General section) // --------------------------------------------------------------------------- @@ -709,6 +740,26 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null { ); } +/** + * Local daemon first, then remaining hosts in their existing order. Lets the + * picker and the active-host resolver agree on a stable "first" host. + */ +function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] { + return useMemo(() => { + if (!localServerId) { + return hosts; + } + const localIndex = hosts.findIndex((host) => host.serverId === localServerId); + if (localIndex <= 0) { + return hosts; + } + const next = hosts.slice(); + const [local] = next.splice(localIndex, 1); + next.unshift(local); + return next; + }, [hosts, localServerId]); +} + interface SidebarSectionButtonProps { itemId: SettingsSectionSlug; label: string; @@ -751,6 +802,49 @@ function SidebarSectionButton({ ); } +interface SidebarHostSectionButtonProps { + itemId: HostSectionSlug; + label: string; + icon: ComponentType<{ size: number; color: string }>; + isSelected: boolean; + onSelect: (section: HostSectionSlug) => void; +} + +function SidebarHostSectionButton({ + itemId, + label, + icon: IconComponent, + isSelected, + onSelect, +}: SidebarHostSectionButtonProps) { + const { theme } = useUnistyles(); + const handlePress = useCallback(() => { + onSelect(itemId); + }, [onSelect, itemId]); + const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); + const labelStyle = useMemo( + () => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }], + [isSelected, theme.colors.foreground], + ); + return ( + + + + {label} + + + ); +} + interface SidebarProjectsButtonProps { isSelected: boolean; onSelect: () => void; @@ -782,83 +876,213 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr ); } -interface SidebarHostItemProps { +// Sentinel option id for the "Add host" row appended to the picker list. +const ADD_HOST_OPTION_ID = "__add_host__"; + +interface HostPickerOptionProps { serverId: string; label: string; - isSelected: boolean; isLocal: boolean; - onSelect: (serverId: string) => void; + selected: boolean; + active: boolean; + onPress: () => void; } -function SidebarHostItem({ serverId, label, isSelected, isLocal, onSelect }: SidebarHostItemProps) { +function HostPickerOption({ + serverId, + label, + isLocal, + selected, + active, + onPress, +}: HostPickerOptionProps) { const { theme } = useUnistyles(); - const handlePress = useCallback(() => { - onSelect(serverId); - }, [onSelect, serverId]); - const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); - const labelStyle = useMemo( - () => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }], - [isSelected, theme.colors.foreground], + const leadingSlot = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foregroundMuted], ); - return ( - - - - {label} - - {isLocal ? ( + // The local host carries a "Local" marker; the active host is conveyed by the + // row's selected check, so both can coexist on one row. + const trailingSlot = useMemo( + () => + isLocal ? ( Local - ) : null} - + ) : undefined, + [isLocal], + ); + return ( + + ); +} + +function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) { + const { theme } = useUnistyles(); + const leadingSlot = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foregroundMuted], + ); + return ( + + ); +} + +interface HostPickerProps { + activeServerId: string | null; + sortedHosts: HostProfile[]; + localServerId: string | null; + onSelectHost: (serverId: string) => void; + onAddHost: () => void; +} + +/** + * Scopes the four host sections to a host. Reuses the canonical sidebar host + * switcher pattern (left-sidebar.tsx): a quiet row-styled trigger opening a + * . The local host is listed first and tagged "Local"; an "Add host" + * row is always reachable from the list — even with a single host. + */ +function HostPicker({ + activeServerId, + sortedHosts, + localServerId, + onSelectHost, + onAddHost, +}: HostPickerProps) { + const { theme } = useUnistyles(); + const [isOpen, setIsOpen] = useState(false); + const triggerRef = useRef(null); + const activeHost = + sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null; + + const options = useMemo(() => { + const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label })); + return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: "Add host" }]; + }, [sortedHosts]); + + const handleSelect = useCallback( + (id: string) => { + if (id === ADD_HOST_OPTION_ID) { + onAddHost(); + return; + } + onSelectHost(id); + }, + [onAddHost, onSelectHost], + ); + + const renderOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }): ReactElement => { + if (option.id === ADD_HOST_OPTION_ID) { + return ; + } + return ( + + ); + }, + [localServerId], + ); + + const handleOpen = useCallback(() => setIsOpen(true), []); + const triggerStyle = useCallback( + ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + sidebarStyles.pickerTrigger, + hovered && sidebarStyles.pickerTriggerHovered, + ], + [], + ); + + return ( + <> + + + + {activeHost?.label ?? "Host"} + + + + + ); } interface SettingsSidebarProps { view: SettingsView; onSelectSection: (section: SettingsSectionSlug) => void; + onSelectHostSection: (section: HostSectionSlug) => void; onSelectHost: (serverId: string) => void; onSelectProjects: () => void; onAddHost: () => void; onBackToWorkspace: () => void; + activeHostServerId: string | null; layout: "desktop" | "mobile"; } function SettingsSidebar({ view, onSelectSection, + onSelectHostSection, onSelectHost, onSelectProjects, onAddHost, onBackToWorkspace, + activeHostServerId, layout, }: SettingsSidebarProps) { const { theme } = useUnistyles(); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); - const sortedHosts = useMemo(() => { - if (!localServerId) { - return hosts; - } - const localIndex = hosts.findIndex((host) => host.serverId === localServerId); - if (localIndex <= 0) { - return hosts; - } - const next = hosts.slice(); - const [local] = next.splice(localIndex, 1); - next.unshift(local); - return next; - }, [hosts, localServerId]); + const sortedHosts = useSortedHosts(hosts, localServerId); + const hasHosts = sortedHosts.length > 0; const isDesktopApp = isElectronRuntime(); const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp); const insets = useSafeAreaInsets(); @@ -872,7 +1096,7 @@ function SettingsSidebar({ [insets.top, isDesktop], ); const selectedSectionId = view.kind === "section" ? view.section : null; - const selectedServerId = view.kind === "host" ? view.serverId : null; + const selectedHostSection = view.kind === "host" ? view.section : null; const isProjectsSelected = view.kind === "projects" || view.kind === "project"; const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]); @@ -893,6 +1117,7 @@ function SettingsSidebar({ /> ) : null} + App {items.map((item) => ( - - {sortedHosts.map((host) => ( - + Host + - ))} - - - - Add host - - - + {HOST_SECTION_ITEMS.map((item) => ( + + ))} + + ) : ( + + + + + Add host + + + + )} ); } @@ -967,9 +1205,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { [webScrollbarStyle], ); const hosts = useHosts(); + const localServerId = useLocalDaemonServerId(); + const sortedHosts = useSortedHosts(hosts, localServerId); const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds); + // The host the four sections scope to: the host on the active view, otherwise + // the local daemon, otherwise the first available host. + const activeHostServerId = useMemo(() => { + if (view.kind === "host") return view.serverId; + return localServerId ?? sortedHosts[0]?.serverId ?? null; + }, [view, localServerId, sortedHosts]); + const handleThemeChange = useCallback( (nextTheme: AppSettings["theme"]) => { void updateSettings({ theme: nextTheme }); @@ -1055,7 +1302,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { const handleHostAdded = useCallback( ({ serverId }: { serverId: string }) => { - const target = buildSettingsHostRoute(serverId); + const target = buildSettingsHostSectionRoute(serverId, "connections"); if (isCompactLayout) { router.push(target); } else { @@ -1077,16 +1324,34 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { [isCompactLayout, router], ); + // Picker: swap the host but keep the section the user is already looking at. const handleSelectHost = useCallback( (serverId: string) => { - const target = buildSettingsHostRoute(serverId); + const section: HostSectionSlug = view.kind === "host" ? view.section : "connections"; + const target = buildSettingsHostSectionRoute(serverId, section); if (isCompactLayout) { router.push(target); } else { router.replace(target); } }, - [isCompactLayout, router], + [isCompactLayout, router, view], + ); + + const handleSelectHostSection = useCallback( + (section: HostSectionSlug) => { + if (!activeHostServerId) { + handleAddHost(); + return; + } + const target = buildSettingsHostSectionRoute(activeHostServerId, section); + if (isCompactLayout) { + router.push(target); + } else { + router.replace(target); + } + }, + [activeHostServerId, handleAddHost, isCompactLayout, router], ); const handleSelectProjects = useCallback(() => { @@ -1140,13 +1405,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { titleAccessory?: ReactNode; } | null => { if (view.kind === "host") { - const host = hosts.find((h) => h.serverId === view.serverId); - if (!host) return null; - return { - title: host.label, - Icon: Server, - titleAccessory: , - }; + const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); + if (!item) return null; + return { title: item.label, Icon: item.icon }; } if (view.kind === "section") { const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section); @@ -1161,7 +1422,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { const content = (() => { if (view.kind === "host") { - return ; + switch (view.section) { + case "connections": + return ; + case "orchestration": + return ; + case "providers": + return ; + case "daemon": + return ; + } } if (view.kind === "projects") { return ; @@ -1251,10 +1521,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { @@ -1293,10 +1565,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { @@ -1448,6 +1722,13 @@ const sidebarStyles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], gap: theme.spacing[1], }, + groupLabel: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + }, item: { flexDirection: "row", alignItems: "center", @@ -1472,11 +1753,25 @@ const sidebarStyles = StyleSheet.create((theme) => ({ localMarker: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, + marginLeft: theme.spacing[1], + }, + pickerTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 36, + paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], - paddingVertical: 2, - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.surface3, + borderRadius: theme.borderRadius.lg, + }, + pickerTriggerHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + pickerTriggerLabel: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.base, + color: theme.colors.foreground, + fontWeight: theme.fontWeight.normal, }, })); diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 221e16a53..dc561c28a 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -74,25 +74,30 @@ function formatDaemonVersionBadge(version: string | null): string | null { const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" }; const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" }; -export interface HostPageProps { - serverId: string; - onHostRemoved?: () => void; +function useHostProfile(serverId: string): HostProfile | null { + const daemons = useHosts(); + return daemons.find((entry) => entry.serverId === serverId) ?? null; } -export function HostPage({ serverId, onHostRemoved }: HostPageProps) { - const daemons = useHosts(); - const host = daemons.find((entry) => entry.serverId === serverId) ?? null; +function HostNotFound() { + return ( + + + Host not found + + + ); +} + +function HostStatusBadges({ serverId }: { serverId: string }) { const { theme } = useUnistyles(); const snapshot = useHostRuntimeSnapshot(serverId); - const isLocalDaemon = useIsLocalDaemon(serverId); - const daemonVersion = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.version ?? null, ); const connectionStatus = snapshot?.connectionStatus ?? "connecting"; const activeConnection = snapshot?.activeConnection ?? null; - const lastError = snapshot?.lastError ?? null; const statusLabel = formatConnectionStatus(connectionStatus); const statusTone = getConnectionStatusTone(connectionStatus); let statusColor: string; @@ -117,8 +122,6 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) { } const connectionBadge = formatActiveConnectionBadge(activeConnection, theme); const versionBadgeText = formatDaemonVersionBadge(daemonVersion); - const connectionError = - typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; const statusPillStyle = useMemo( () => [styles.statusPill, { backgroundColor: statusPillBg }], @@ -130,46 +133,125 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) { ); const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]); - if (!host) { - return ( - - - Host not found - + return ( + + + + {statusLabel} - ); + {connectionBadge ? ( + + {connectionBadge.icon} + + {connectionBadge.text} + + + ) : null} + {versionBadgeText ? ( + + + {versionBadgeText} + + + ) : null} + + ); +} + +function HostConnectionError({ serverId }: { serverId: string }) { + const snapshot = useHostRuntimeSnapshot(serverId); + const lastError = snapshot?.lastError ?? null; + const connectionError = + typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; + if (!connectionError) return null; + return {connectionError}; +} + +export function HostConnectionsPage({ serverId }: { serverId: string }) { + const host = useHostProfile(serverId); + const isLocalDaemon = useIsLocalDaemon(serverId); + + if (!host) { + return ; } return ( - - - - - {statusLabel} - - {connectionBadge ? ( - - {connectionBadge.icon} - - {connectionBadge.text} - - - ) : null} - {versionBadgeText ? ( - - - {versionBadgeText} - - - ) : null} - - {connectionError ? {connectionError} : null} - + + + {isLocalDaemon ? ( + + + + ) : null} + + ); +} - +export function HostOrchestrationPage({ serverId }: { serverId: string }) { + const host = useHostProfile(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + if (!host) { + return ; + } + + return ( + + {isConnected ? ( + + + + + ) : ( + + Connect to this host to manage orchestration + + )} + + ); +} + +export function HostProvidersPage({ serverId }: { serverId: string }) { + const host = useHostProfile(serverId); + + if (!host) { + return ; + } + + return ( + + + ); +} + +export function HostDaemonPage({ + serverId, + onHostRemoved, +}: { + serverId: string; + onHostRemoved?: () => void; +}) { + const host = useHostProfile(serverId); + const isLocalDaemon = useIsLocalDaemon(serverId); + + if (!host) { + return ; + } + + return ( + + + + {host.label} + + + + + + + {isLocalDaemon ? : null} @@ -337,7 +419,7 @@ function ConnectionRow({ if (latencyLoading) return "..."; if (latencyError) return "Timeout"; if (latencyMs != null) return formatLatency(latencyMs); - return "\u2014"; + return "—"; })(); const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted; @@ -378,23 +460,6 @@ function ConnectionRow({ ); } -function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) { - return ( - <> - - - - - {isLocalDaemon ? ( - - - - ) : null} - {isLocalDaemon ? : null} - - ); -} - const delay = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); @@ -612,7 +677,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) { System prompt - Added a system prompt to all agents + Adds a system prompt to all agents