Flatten settings sidebar with a host picker (#1241)
* Flatten settings sidebar with a host picker Settings had two implicit tiers: app-level sections as flat sidebar rows, and per-host settings hidden behind a drill-in to one monolithic host page. This flattens both into a single sidebar with an "App" group and a "Host" group, where the host's settings are top-level rows scoped by a host picker. - Split the monolithic HostPage into four section pages: Connections, Orchestration (was the unnamed inject-tools + system-prompt block), Providers, and Daemon (status, updates, restart, remove). - Host picker reuses the canonical <Combobox> sidebar host switcher; defaults to the local host so a single-local-host user never thinks about "the daemon". - Host sections become real sub-routes (/settings/hosts/[id]/[section]); the old /settings/hosts/[id] redirects to the connections section, so existing deep links (pairing, workspace, open-project) keep working. - "Open providers" from the project screen now lands on the Providers section. * Fix e2e host-settings navigation for the flat sidebar The flat-settings refactor moved host settings onto separate section pages, but three e2e helpers still expected the old monolithic host page: - openDesktopSettings: the daemon-lifecycle card now lives on the Daemon section — navigate there before asserting it. - acp catalog: "Add provider" now lives on the Providers section — open that section before opening the add-provider modal. - openAddHostFlow: "Add host" is now an item inside the host picker (Combobox), not a standalone button — open the picker first. Test-only; no product changes. Fixes the 8 playwright failures on this branch.
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
installAcpCatalogProvider,
|
installAcpCatalogProvider,
|
||||||
openAddProviderModal,
|
openAddProviderModal,
|
||||||
openSettingsHost,
|
openSettingsHost,
|
||||||
|
openSettingsHostSection,
|
||||||
} from "./helpers/settings";
|
} from "./helpers/settings";
|
||||||
|
|
||||||
const ACP_PROVIDER = {
|
const ACP_PROVIDER = {
|
||||||
@@ -18,6 +19,8 @@ test.describe("ACP provider catalog", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, getServerId());
|
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 openAddProviderModal(page);
|
||||||
|
|
||||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { openSettings } from "./app";
|
import { openSettings } from "./app";
|
||||||
import { getE2EDaemonPort } from "./daemon-port";
|
import { getE2EDaemonPort } from "./daemon-port";
|
||||||
import { openSettingsHost } from "./settings";
|
import { openSettingsHost, openSettingsHostSection } from "./settings";
|
||||||
|
|
||||||
interface DaemonApiStatus {
|
interface DaemonApiStatus {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -208,6 +208,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
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({
|
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { TEST_HOST_LABEL } from "./daemon-registry";
|
||||||
import { escapeRegex } from "./regex";
|
import { escapeRegex } from "./regex";
|
||||||
import { getServerId } from "./server-id";
|
import { getServerId } from "./server-id";
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ const SECTION_LABELS = {
|
|||||||
|
|
||||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||||
|
|
||||||
|
type HostSection = "connections" | "orchestration" | "providers" | "daemon";
|
||||||
|
|
||||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
await expect(sidebar).toBeVisible();
|
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<void> {
|
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
// Host sections are now flat top-level rows under the Host group. Navigate by
|
||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
// 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<void> {
|
||||||
|
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<void> {
|
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||||
@@ -37,6 +54,9 @@ export async function expectSettingsHeader(page: Page, title: string): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openAddHostFlow(page: Page): Promise<void> {
|
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||||
|
// "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 page.getByTestId("settings-add-host").click();
|
||||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
@@ -68,7 +88,7 @@ export async function expectCompactSettingsList(page: Page): Promise<void> {
|
|||||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||||
await expect(page.getByRole("button", { name: "Play test" })).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<void> {
|
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
|
||||||
@@ -105,7 +125,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
|
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
|
||||||
await expect(page).toHaveURL(
|
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<void> {
|
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
||||||
const card = page.getByTestId("host-page-connections-card");
|
const card = page.getByTestId("host-page-connections-card");
|
||||||
await expect(card).toBeVisible();
|
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(
|
await expect(
|
||||||
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -193,14 +217,29 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
|||||||
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectHostActionCards(page: Page): Promise<void> {
|
export async function openHostSection(
|
||||||
|
page: Page,
|
||||||
|
serverId: string,
|
||||||
|
section: HostSection,
|
||||||
|
): Promise<void> {
|
||||||
|
await openSettingsHostSection(page, serverId, section);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
|
||||||
|
// 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-card")).toBeVisible();
|
||||||
await expect(page.getByTestId("host-page-restart-button")).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-card")).toBeVisible();
|
||||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectHostProvidersCard(page: Page, serverId: string): Promise<void> {
|
||||||
|
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<void> {
|
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
|
||||||
await page.route(url, async (route) => {
|
await page.route(url, async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@@ -244,27 +283,35 @@ export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
|
|||||||
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
|
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
await expect(sidebar).toBeVisible();
|
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);
|
// App group rows remain top-level.
|
||||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
|
||||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
|
||||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
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: "Diagnostics", exact: true })).toBeVisible();
|
||||||
await expect(sidebar.getByRole("button", { name: "About", 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<void> {
|
export async function expectHostPageVisible(page: Page, _serverId: string): Promise<void> {
|
||||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
|
export async function expectLocalHostEntryFirst(page: Page, _serverId: string): Promise<void> {
|
||||||
const sidebar = page.getByTestId("settings-sidebar");
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
|
|
||||||
"data-testid",
|
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
|
||||||
`settings-host-entry-${serverId}`,
|
// 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
|
||||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
// exercise.
|
||||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
const picker = sidebar.getByTestId("settings-host-picker");
|
||||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
await expect(picker).toBeVisible();
|
||||||
|
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { getServerId } from "./helpers/server-id";
|
|||||||
import {
|
import {
|
||||||
expectSettingsHeader,
|
expectSettingsHeader,
|
||||||
openSettingsHost,
|
openSettingsHost,
|
||||||
|
openHostSection,
|
||||||
expectHostLabelDisplayed,
|
expectHostLabelDisplayed,
|
||||||
clickEditHostLabel,
|
clickEditHostLabel,
|
||||||
expectHostLabelEditMode,
|
expectHostLabelEditMode,
|
||||||
expectHostConnectionsCard,
|
expectHostConnectionsCard,
|
||||||
expectHostInjectMcpCard,
|
expectHostInjectMcpCard,
|
||||||
expectHostActionCards,
|
expectHostActionCards,
|
||||||
|
expectHostProvidersCard,
|
||||||
expectHostNoLocalOnlyRows,
|
expectHostNoLocalOnlyRows,
|
||||||
expectRetiredSidebarSectionsAbsent,
|
expectRetiredSidebarSectionsAbsent,
|
||||||
expectHostPageVisible,
|
expectHostPageVisible,
|
||||||
@@ -19,9 +21,7 @@ import {
|
|||||||
} from "./helpers/settings";
|
} from "./helpers/settings";
|
||||||
|
|
||||||
test.describe("Settings host page", () => {
|
test.describe("Settings host page", () => {
|
||||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
test("connections section shows the seeded connection endpoint", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const port = getE2EDaemonPort();
|
const port = getE2EDaemonPort();
|
||||||
|
|
||||||
@@ -29,11 +29,44 @@ test.describe("Settings host page", () => {
|
|||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
|
||||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
await expectSettingsHeader(page, "Connections");
|
||||||
await expectHostLabelDisplayed(page);
|
|
||||||
await expectHostConnectionsCard(page, port);
|
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 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 }) => {
|
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||||
@@ -42,13 +75,14 @@ test.describe("Settings host page", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
await openHostSection(page, serverId, "daemon");
|
||||||
|
|
||||||
await expectHostLabelDisplayed(page);
|
await expectHostLabelDisplayed(page);
|
||||||
await clickEditHostLabel(page);
|
await clickEditHostLabel(page);
|
||||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
@@ -56,19 +90,20 @@ test.describe("Settings host page", () => {
|
|||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
|
await openHostSection(page, serverId, "daemon");
|
||||||
|
|
||||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||||
await expectHostNoLocalOnlyRows(page);
|
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 gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|
||||||
await expectRetiredSidebarSectionsAbsent(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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
@@ -77,9 +112,10 @@ test.describe("Settings host page", () => {
|
|||||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||||
|
|
||||||
await expectHostPageVisible(page, serverId);
|
await expectHostPageVisible(page, serverId);
|
||||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
await expectSettingsHeader(page, "Connections");
|
||||||
|
await openHostSection(page, serverId, "daemon");
|
||||||
await expectHostLabelDisplayed(page);
|
await expectHostLabelDisplayed(page);
|
||||||
await expectHostActionCards(page);
|
await expectHostActionCards(page, serverId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
await expectGeneralContent(page);
|
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 gotoAppShell(page);
|
||||||
await verifyLegacyHostSettingsRedirect(page);
|
await verifyLegacyHostSettingsRedirect(page);
|
||||||
});
|
});
|
||||||
@@ -132,7 +132,9 @@ test.describe("Settings — compact master-detail", () => {
|
|||||||
await expectSettingsBackButton(page);
|
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 gotoAppShell(page);
|
||||||
await openCompactSettings(page);
|
await openCompactSettings(page);
|
||||||
|
|
||||||
|
|||||||
@@ -867,7 +867,8 @@ function RootStack() {
|
|||||||
<Stack.Screen name="h/[serverId]/sessions" />
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
<Stack.Screen name="settings/hosts/[serverId]" />
|
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||||
|
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<SettingsScreen view={view} />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<SettingsScreen view={view} />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <Redirect href={buildSettingsRoute()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Redirect href={buildSettingsHostSectionRoute(serverId, "connections")} />;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
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 { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
@@ -62,7 +62,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenProviders = useCallback(() => {
|
const handleOpenProviders = useCallback(() => {
|
||||||
router.push(buildSettingsHostRoute(serverId));
|
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
|
||||||
}, [router, serverId]);
|
}, [router, serverId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import {
|
||||||
import type { ComponentType, ReactNode } from "react";
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
|
import type { ComponentType, ReactElement, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -22,6 +30,9 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Settings,
|
Settings,
|
||||||
Server,
|
Server,
|
||||||
|
Network,
|
||||||
|
Workflow,
|
||||||
|
Boxes,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
Info,
|
Info,
|
||||||
@@ -72,6 +83,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||||
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
@@ -81,7 +93,12 @@ import { resolveAppVersion } from "@/utils/app-version";
|
|||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
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 ProjectsScreen from "@/screens/projects-screen";
|
||||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -90,8 +107,9 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
|||||||
import {
|
import {
|
||||||
buildHostOpenProjectRoute,
|
buildHostOpenProjectRoute,
|
||||||
buildProjectsSettingsRoute,
|
buildProjectsSettingsRoute,
|
||||||
buildSettingsHostRoute,
|
buildSettingsHostSectionRoute,
|
||||||
buildSettingsSectionRoute,
|
buildSettingsSectionRoute,
|
||||||
|
type HostSectionSlug,
|
||||||
type SettingsSectionSlug,
|
type SettingsSectionSlug,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
@@ -103,7 +121,7 @@ import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-st
|
|||||||
export type SettingsView =
|
export type SettingsView =
|
||||||
| { kind: "root" }
|
| { kind: "root" }
|
||||||
| { kind: "section"; section: SettingsSectionSlug }
|
| { kind: "section"; section: SettingsSectionSlug }
|
||||||
| { kind: "host"; serverId: string }
|
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
||||||
| { kind: "projects" }
|
| { kind: "projects" }
|
||||||
| { kind: "project"; projectKey: string };
|
| { kind: "project"; projectKey: string };
|
||||||
|
|
||||||
@@ -123,6 +141,19 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
|||||||
{ id: "about", label: "About", icon: Info },
|
{ 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)
|
// 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 {
|
interface SidebarSectionButtonProps {
|
||||||
itemId: SettingsSectionSlug;
|
itemId: SettingsSectionSlug;
|
||||||
label: string;
|
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 (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
onPress={handlePress}
|
||||||
|
testID={`settings-host-section-${itemId}`}
|
||||||
|
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
size={theme.iconSize.md}
|
||||||
|
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
<Text style={labelStyle} numberOfLines={1}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface SidebarProjectsButtonProps {
|
interface SidebarProjectsButtonProps {
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: () => void;
|
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;
|
serverId: string;
|
||||||
label: string;
|
label: string;
|
||||||
isSelected: boolean;
|
|
||||||
isLocal: 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 { theme } = useUnistyles();
|
||||||
const handlePress = useCallback(() => {
|
const leadingSlot = useMemo(
|
||||||
onSelect(serverId);
|
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
}, [onSelect, serverId]);
|
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
|
||||||
const labelStyle = useMemo(
|
|
||||||
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
|
||||||
[isSelected, theme.colors.foreground],
|
|
||||||
);
|
);
|
||||||
return (
|
// The local host carries a "Local" marker; the active host is conveyed by the
|
||||||
<Pressable
|
// row's selected check, so both can coexist on one row.
|
||||||
accessibilityRole="button"
|
const trailingSlot = useMemo(
|
||||||
accessibilityState={accessibilityState}
|
() =>
|
||||||
onPress={handlePress}
|
isLocal ? (
|
||||||
testID={`settings-host-entry-${serverId}`}
|
|
||||||
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
|
||||||
>
|
|
||||||
<Server
|
|
||||||
size={theme.iconSize.md}
|
|
||||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
<Text style={labelStyle} numberOfLines={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
{isLocal ? (
|
|
||||||
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
|
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
|
||||||
Local
|
Local
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : undefined,
|
||||||
</Pressable>
|
[isLocal],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
label={label}
|
||||||
|
leadingSlot={leadingSlot}
|
||||||
|
trailingSlot={trailingSlot}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
testID={`settings-host-picker-item-${serverId}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const leadingSlot = useMemo(
|
||||||
|
() => <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||||
|
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
label="Add host"
|
||||||
|
leadingSlot={leadingSlot}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
testID="settings-add-host"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
* <Combobox>. 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<View | null>(null);
|
||||||
|
const activeHost =
|
||||||
|
sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null;
|
||||||
|
|
||||||
|
const options = useMemo<ComboboxOption[]>(() => {
|
||||||
|
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 <AddHostOption active={active} onPress={onPress} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<HostPickerOption
|
||||||
|
serverId={option.id}
|
||||||
|
label={option.label}
|
||||||
|
isLocal={localServerId !== null && option.id === localServerId}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[localServerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
|
const triggerStyle = useCallback(
|
||||||
|
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
sidebarStyles.pickerTrigger,
|
||||||
|
hovered && sidebarStyles.pickerTriggerHovered,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
ref={triggerRef}
|
||||||
|
style={triggerStyle}
|
||||||
|
onPress={handleOpen}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Switch host"
|
||||||
|
testID="settings-host-picker"
|
||||||
|
>
|
||||||
|
<Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
|
||||||
|
{activeHost?.label ?? "Host"}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
<Combobox
|
||||||
|
options={options}
|
||||||
|
value={activeServerId ?? ""}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
renderOption={renderOption}
|
||||||
|
searchable={false}
|
||||||
|
title="Switch host"
|
||||||
|
desktopMinWidth={240}
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
anchorRef={triggerRef}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsSidebarProps {
|
interface SettingsSidebarProps {
|
||||||
view: SettingsView;
|
view: SettingsView;
|
||||||
onSelectSection: (section: SettingsSectionSlug) => void;
|
onSelectSection: (section: SettingsSectionSlug) => void;
|
||||||
|
onSelectHostSection: (section: HostSectionSlug) => void;
|
||||||
onSelectHost: (serverId: string) => void;
|
onSelectHost: (serverId: string) => void;
|
||||||
onSelectProjects: () => void;
|
onSelectProjects: () => void;
|
||||||
onAddHost: () => void;
|
onAddHost: () => void;
|
||||||
onBackToWorkspace: () => void;
|
onBackToWorkspace: () => void;
|
||||||
|
activeHostServerId: string | null;
|
||||||
layout: "desktop" | "mobile";
|
layout: "desktop" | "mobile";
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsSidebar({
|
function SettingsSidebar({
|
||||||
view,
|
view,
|
||||||
onSelectSection,
|
onSelectSection,
|
||||||
|
onSelectHostSection,
|
||||||
onSelectHost,
|
onSelectHost,
|
||||||
onSelectProjects,
|
onSelectProjects,
|
||||||
onAddHost,
|
onAddHost,
|
||||||
onBackToWorkspace,
|
onBackToWorkspace,
|
||||||
|
activeHostServerId,
|
||||||
layout,
|
layout,
|
||||||
}: SettingsSidebarProps) {
|
}: SettingsSidebarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const localServerId = useLocalDaemonServerId();
|
const localServerId = useLocalDaemonServerId();
|
||||||
const sortedHosts = useMemo(() => {
|
const sortedHosts = useSortedHosts(hosts, localServerId);
|
||||||
if (!localServerId) {
|
const hasHosts = sortedHosts.length > 0;
|
||||||
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 isDesktopApp = isElectronRuntime();
|
const isDesktopApp = isElectronRuntime();
|
||||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -872,7 +1096,7 @@ function SettingsSidebar({
|
|||||||
[insets.top, isDesktop],
|
[insets.top, isDesktop],
|
||||||
);
|
);
|
||||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
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 isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||||
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||||
|
|
||||||
@@ -893,6 +1117,7 @@ function SettingsSidebar({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={sidebarStyles.list}>
|
<View style={sidebarStyles.list}>
|
||||||
|
<Text style={sidebarStyles.groupLabel}>App</Text>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Fragment key={item.id}>
|
<Fragment key={item.id}>
|
||||||
<SidebarSectionButton
|
<SidebarSectionButton
|
||||||
@@ -909,30 +1134,43 @@ function SettingsSidebar({
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<View style={sidebarStyles.list}>
|
{hasHosts ? (
|
||||||
{sortedHosts.map((host) => (
|
<View style={sidebarStyles.list}>
|
||||||
<SidebarHostItem
|
<Text style={sidebarStyles.groupLabel}>Host</Text>
|
||||||
key={host.serverId}
|
<HostPicker
|
||||||
serverId={host.serverId}
|
activeServerId={activeHostServerId}
|
||||||
label={host.label}
|
sortedHosts={sortedHosts}
|
||||||
isSelected={selectedServerId === host.serverId}
|
localServerId={localServerId}
|
||||||
isLocal={localServerId !== null && host.serverId === localServerId}
|
onSelectHost={onSelectHost}
|
||||||
onSelect={onSelectHost}
|
onAddHost={onAddHost}
|
||||||
/>
|
/>
|
||||||
))}
|
{HOST_SECTION_ITEMS.map((item) => (
|
||||||
<Pressable
|
<SidebarHostSectionButton
|
||||||
accessibilityRole="button"
|
key={item.id}
|
||||||
accessibilityLabel="Add host"
|
itemId={item.id}
|
||||||
onPress={onAddHost}
|
label={item.label}
|
||||||
testID="settings-add-host"
|
icon={item.icon}
|
||||||
style={sidebarItemStyle}
|
isSelected={selectedHostSection === item.id}
|
||||||
>
|
onSelect={onSelectHostSection}
|
||||||
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
/>
|
||||||
<Text style={sidebarStyles.label} numberOfLines={1}>
|
))}
|
||||||
Add host
|
</View>
|
||||||
</Text>
|
) : (
|
||||||
</Pressable>
|
<View style={sidebarStyles.list}>
|
||||||
</View>
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Add host"
|
||||||
|
onPress={onAddHost}
|
||||||
|
testID="settings-add-host"
|
||||||
|
style={sidebarItemStyle}
|
||||||
|
>
|
||||||
|
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sidebarStyles.label} numberOfLines={1}>
|
||||||
|
Add host
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -967,9 +1205,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
[webScrollbarStyle],
|
[webScrollbarStyle],
|
||||||
);
|
);
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
|
const localServerId = useLocalDaemonServerId();
|
||||||
|
const sortedHosts = useSortedHosts(hosts, localServerId);
|
||||||
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||||
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
|
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(
|
const handleThemeChange = useCallback(
|
||||||
(nextTheme: AppSettings["theme"]) => {
|
(nextTheme: AppSettings["theme"]) => {
|
||||||
void updateSettings({ theme: nextTheme });
|
void updateSettings({ theme: nextTheme });
|
||||||
@@ -1055,7 +1302,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
|
|
||||||
const handleHostAdded = useCallback(
|
const handleHostAdded = useCallback(
|
||||||
({ serverId }: { serverId: string }) => {
|
({ serverId }: { serverId: string }) => {
|
||||||
const target = buildSettingsHostRoute(serverId);
|
const target = buildSettingsHostSectionRoute(serverId, "connections");
|
||||||
if (isCompactLayout) {
|
if (isCompactLayout) {
|
||||||
router.push(target);
|
router.push(target);
|
||||||
} else {
|
} else {
|
||||||
@@ -1077,16 +1324,34 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
[isCompactLayout, router],
|
[isCompactLayout, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Picker: swap the host but keep the section the user is already looking at.
|
||||||
const handleSelectHost = useCallback(
|
const handleSelectHost = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
const target = buildSettingsHostRoute(serverId);
|
const section: HostSectionSlug = view.kind === "host" ? view.section : "connections";
|
||||||
|
const target = buildSettingsHostSectionRoute(serverId, section);
|
||||||
if (isCompactLayout) {
|
if (isCompactLayout) {
|
||||||
router.push(target);
|
router.push(target);
|
||||||
} else {
|
} else {
|
||||||
router.replace(target);
|
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(() => {
|
const handleSelectProjects = useCallback(() => {
|
||||||
@@ -1140,13 +1405,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
titleAccessory?: ReactNode;
|
titleAccessory?: ReactNode;
|
||||||
} | null => {
|
} | null => {
|
||||||
if (view.kind === "host") {
|
if (view.kind === "host") {
|
||||||
const host = hosts.find((h) => h.serverId === view.serverId);
|
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||||
if (!host) return null;
|
if (!item) return null;
|
||||||
return {
|
return { title: item.label, Icon: item.icon };
|
||||||
title: host.label,
|
|
||||||
Icon: Server,
|
|
||||||
titleAccessory: <HostRenameButton host={host} />,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (view.kind === "section") {
|
if (view.kind === "section") {
|
||||||
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||||
@@ -1161,7 +1422,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
|
|
||||||
const content = (() => {
|
const content = (() => {
|
||||||
if (view.kind === "host") {
|
if (view.kind === "host") {
|
||||||
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
switch (view.section) {
|
||||||
|
case "connections":
|
||||||
|
return <HostConnectionsPage serverId={view.serverId} />;
|
||||||
|
case "orchestration":
|
||||||
|
return <HostOrchestrationPage serverId={view.serverId} />;
|
||||||
|
case "providers":
|
||||||
|
return <HostProvidersPage serverId={view.serverId} />;
|
||||||
|
case "daemon":
|
||||||
|
return <HostDaemonPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (view.kind === "projects") {
|
if (view.kind === "projects") {
|
||||||
return <ProjectsScreen view={view} />;
|
return <ProjectsScreen view={view} />;
|
||||||
@@ -1251,10 +1521,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
<SettingsSidebar
|
<SettingsSidebar
|
||||||
view={view}
|
view={view}
|
||||||
onSelectSection={handleSelectSection}
|
onSelectSection={handleSelectSection}
|
||||||
|
onSelectHostSection={handleSelectHostSection}
|
||||||
onSelectHost={handleSelectHost}
|
onSelectHost={handleSelectHost}
|
||||||
onSelectProjects={handleSelectProjects}
|
onSelectProjects={handleSelectProjects}
|
||||||
onAddHost={handleAddHost}
|
onAddHost={handleAddHost}
|
||||||
onBackToWorkspace={handleBackToWorkspace}
|
onBackToWorkspace={handleBackToWorkspace}
|
||||||
|
activeHostServerId={activeHostServerId}
|
||||||
layout="mobile"
|
layout="mobile"
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -1293,10 +1565,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
<SettingsSidebar
|
<SettingsSidebar
|
||||||
view={view}
|
view={view}
|
||||||
onSelectSection={handleSelectSection}
|
onSelectSection={handleSelectSection}
|
||||||
|
onSelectHostSection={handleSelectHostSection}
|
||||||
onSelectHost={handleSelectHost}
|
onSelectHost={handleSelectHost}
|
||||||
onSelectProjects={handleSelectProjects}
|
onSelectProjects={handleSelectProjects}
|
||||||
onAddHost={handleAddHost}
|
onAddHost={handleAddHost}
|
||||||
onBackToWorkspace={handleBackToWorkspace}
|
onBackToWorkspace={handleBackToWorkspace}
|
||||||
|
activeHostServerId={activeHostServerId}
|
||||||
layout="desktop"
|
layout="desktop"
|
||||||
/>
|
/>
|
||||||
<View style={desktopStyles.contentPane}>
|
<View style={desktopStyles.contentPane}>
|
||||||
@@ -1448,6 +1722,13 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
gap: theme.spacing[1],
|
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: {
|
item: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -1472,11 +1753,25 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
|||||||
localMarker: {
|
localMarker: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
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],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: 2,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderWidth: 1,
|
},
|
||||||
borderColor: theme.colors.border,
|
pickerTriggerHovered: {
|
||||||
borderRadius: theme.borderRadius.full,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
backgroundColor: theme.colors.surface3,
|
},
|
||||||
|
pickerTriggerLabel: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -74,25 +74,30 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
|||||||
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
||||||
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
|
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
|
||||||
|
|
||||||
export interface HostPageProps {
|
function useHostProfile(serverId: string): HostProfile | null {
|
||||||
serverId: string;
|
const daemons = useHosts();
|
||||||
onHostRemoved?: () => void;
|
return daemons.find((entry) => entry.serverId === serverId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
function HostNotFound() {
|
||||||
const daemons = useHosts();
|
return (
|
||||||
const host = daemons.find((entry) => entry.serverId === serverId) ?? null;
|
<View>
|
||||||
|
<View style={EMPTY_CARD_STYLE}>
|
||||||
|
<Text style={styles.emptyText}>Host not found</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostStatusBadges({ serverId }: { serverId: string }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
|
||||||
|
|
||||||
const daemonVersion = useSessionStore(
|
const daemonVersion = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.serverInfo?.version ?? null,
|
(state) => state.sessions[serverId]?.serverInfo?.version ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
||||||
const activeConnection = snapshot?.activeConnection ?? null;
|
const activeConnection = snapshot?.activeConnection ?? null;
|
||||||
const lastError = snapshot?.lastError ?? null;
|
|
||||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||||
const statusTone = getConnectionStatusTone(connectionStatus);
|
const statusTone = getConnectionStatusTone(connectionStatus);
|
||||||
let statusColor: string;
|
let statusColor: string;
|
||||||
@@ -117,8 +122,6 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
|||||||
}
|
}
|
||||||
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme);
|
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme);
|
||||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||||
const connectionError =
|
|
||||||
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
|
||||||
|
|
||||||
const statusPillStyle = useMemo(
|
const statusPillStyle = useMemo(
|
||||||
() => [styles.statusPill, { backgroundColor: statusPillBg }],
|
() => [styles.statusPill, { backgroundColor: statusPillBg }],
|
||||||
@@ -130,46 +133,125 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
|||||||
);
|
);
|
||||||
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
|
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
|
||||||
|
|
||||||
if (!host) {
|
return (
|
||||||
return (
|
<View style={styles.identityBadges} testID="host-page-identity">
|
||||||
<View testID={`settings-host-page-${serverId}`}>
|
<View style={statusPillStyle}>
|
||||||
<View style={EMPTY_CARD_STYLE}>
|
<View style={statusDotStyle} />
|
||||||
<Text style={styles.emptyText}>Host not found</Text>
|
<Text style={statusTextStyle}>{statusLabel}</Text>
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
{connectionBadge ? (
|
||||||
|
<View style={styles.badgePill}>
|
||||||
|
{connectionBadge.icon}
|
||||||
|
<Text style={styles.badgeText} numberOfLines={1}>
|
||||||
|
{connectionBadge.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{versionBadgeText ? (
|
||||||
|
<View style={styles.badgePill}>
|
||||||
|
<Text style={styles.badgeText} numberOfLines={1}>
|
||||||
|
{versionBadgeText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <Text style={styles.errorText}>{connectionError}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||||
|
const host = useHostProfile(serverId);
|
||||||
|
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return <HostNotFound />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View testID={`settings-host-page-${serverId}`}>
|
<View>
|
||||||
<View style={styles.identityBadges} testID="host-page-identity">
|
<HostConnectionError serverId={serverId} />
|
||||||
<View style={statusPillStyle}>
|
|
||||||
<View style={statusDotStyle} />
|
|
||||||
<Text style={statusTextStyle}>{statusLabel}</Text>
|
|
||||||
</View>
|
|
||||||
{connectionBadge ? (
|
|
||||||
<View style={styles.badgePill}>
|
|
||||||
{connectionBadge.icon}
|
|
||||||
<Text style={styles.badgeText} numberOfLines={1}>
|
|
||||||
{connectionBadge.text}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{versionBadgeText ? (
|
|
||||||
<View style={styles.badgePill}>
|
|
||||||
<Text style={styles.badgeText} numberOfLines={1}>
|
|
||||||
{versionBadgeText}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
{connectionError ? <Text style={styles.errorText}>{connectionError}</Text> : null}
|
|
||||||
|
|
||||||
<ConnectionsSection host={host} />
|
<ConnectionsSection host={host} />
|
||||||
|
{isLocalDaemon ? (
|
||||||
|
<SettingsSection title="Pair devices">
|
||||||
|
<PairDeviceRow />
|
||||||
|
</SettingsSection>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<DaemonSection host={host} isLocalDaemon={isLocalDaemon} />
|
export function HostOrchestrationPage({ serverId }: { serverId: string }) {
|
||||||
|
const host = useHostProfile(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return <HostNotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{isConnected ? (
|
||||||
|
<SettingsSection title="Orchestration">
|
||||||
|
<InjectPaseoToolsCard serverId={serverId} />
|
||||||
|
<AppendSystemPromptCard serverId={serverId} />
|
||||||
|
</SettingsSection>
|
||||||
|
) : (
|
||||||
|
<View style={EMPTY_CARD_STYLE}>
|
||||||
|
<Text style={styles.emptyText}>Connect to this host to manage orchestration</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostProvidersPage({ serverId }: { serverId: string }) {
|
||||||
|
const host = useHostProfile(serverId);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return <HostNotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
<ProvidersSection serverId={serverId} />
|
<ProvidersSection serverId={serverId} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostDaemonPage({
|
||||||
|
serverId,
|
||||||
|
onHostRemoved,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
onHostRemoved?: () => void;
|
||||||
|
}) {
|
||||||
|
const host = useHostProfile(serverId);
|
||||||
|
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return <HostNotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={styles.daemonHeader}>
|
||||||
|
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
|
||||||
|
{host.label}
|
||||||
|
</Text>
|
||||||
|
<HostRenameButton host={host} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<HostStatusBadges serverId={serverId} />
|
||||||
|
|
||||||
|
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||||
|
|
||||||
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
|
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
|
||||||
</View>
|
</View>
|
||||||
@@ -337,7 +419,7 @@ function ConnectionRow({
|
|||||||
if (latencyLoading) return "...";
|
if (latencyLoading) return "...";
|
||||||
if (latencyError) return "Timeout";
|
if (latencyError) return "Timeout";
|
||||||
if (latencyMs != null) return formatLatency(latencyMs);
|
if (latencyMs != null) return formatLatency(latencyMs);
|
||||||
return "\u2014";
|
return "—";
|
||||||
})();
|
})();
|
||||||
const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted;
|
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 (
|
|
||||||
<>
|
|
||||||
<SettingsSection title="Daemon settings">
|
|
||||||
<InjectPaseoToolsCard serverId={host.serverId} />
|
|
||||||
<AppendSystemPromptCard serverId={host.serverId} />
|
|
||||||
</SettingsSection>
|
|
||||||
{isLocalDaemon ? (
|
|
||||||
<SettingsSection title="Pair devices">
|
|
||||||
<PairDeviceRow />
|
|
||||||
</SettingsSection>
|
|
||||||
) : null}
|
|
||||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = (ms: number) =>
|
const delay = (ms: number) =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
setTimeout(resolve, ms);
|
setTimeout(resolve, ms);
|
||||||
@@ -612,7 +677,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
|||||||
<View style={settingsStyles.row}>
|
<View style={settingsStyles.row}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>System prompt</Text>
|
<Text style={settingsStyles.rowTitle}>System prompt</Text>
|
||||||
<Text style={settingsStyles.rowHint}>Added a system prompt to all agents</Text>
|
<Text style={settingsStyles.rowHint}>Adds a system prompt to all agents</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -803,6 +868,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[1],
|
padding: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
|
daemonHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
daemonHeaderLabel: {
|
||||||
|
flexShrink: 1,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
identityBadges: {
|
identityBadges: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -390,6 +390,14 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
|
|||||||
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
|
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const HOST_SECTION_SLUGS = ["connections", "orchestration", "providers", "daemon"] as const;
|
||||||
|
|
||||||
|
export type HostSectionSlug = (typeof HOST_SECTION_SLUGS)[number];
|
||||||
|
|
||||||
|
export function isHostSectionSlug(value: string): value is HostSectionSlug {
|
||||||
|
return (HOST_SECTION_SLUGS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSettingsRoute() {
|
export function buildSettingsRoute() {
|
||||||
return "/settings" as const;
|
return "/settings" as const;
|
||||||
}
|
}
|
||||||
@@ -406,6 +414,14 @@ export function buildSettingsHostRoute(serverId: string) {
|
|||||||
return `/settings/hosts/${encodeSegment(normalized)}` as const;
|
return `/settings/hosts/${encodeSegment(normalized)}` as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSettingsHostSectionRoute(serverId: string, section: HostSectionSlug) {
|
||||||
|
const normalized = trimNonEmpty(serverId);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("buildSettingsHostSectionRoute requires a non-empty serverId");
|
||||||
|
}
|
||||||
|
return `/settings/hosts/${encodeSegment(normalized)}/${section}` as const;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildProjectsSettingsRoute() {
|
export function buildProjectsSettingsRoute() {
|
||||||
return "/settings/projects" as const;
|
return "/settings/projects" as const;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user