From 5c3cc99d8cb112677486b9805bb8785014932fc1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Apr 2026 14:21:40 +0700 Subject: [PATCH 1/3] refactor(app): restructure settings with modular section navigation --- packages/app/e2e/helpers/app.ts | 7 +- packages/app/e2e/settings-host-page.spec.ts | 181 ++ packages/app/e2e/settings-navigation.spec.ts | 178 ++ packages/app/src/app/_layout.tsx | 4 +- packages/app/src/app/h/[serverId]/index.tsx | 97 +- .../app/src/app/h/[serverId]/settings.tsx | 11 +- packages/app/src/app/pair-scan.tsx | 88 +- packages/app/src/app/settings.tsx | 27 - packages/app/src/app/settings/[section].tsx | 11 + .../app/src/app/settings/hosts/[serverId].tsx | 14 + packages/app/src/app/settings/index.tsx | 14 + .../src/components/add-host-method-modal.tsx | 9 +- .../app/src/components/add-host-modal.tsx | 20 +- .../app/src/components/branch-switcher.tsx | 16 +- .../src/components/headers/back-header.tsx | 29 +- .../components/headers/header-icon-badge.tsx | 13 + .../headers/header-toggle-button.tsx | 9 +- .../src/components/headers/menu-header.tsx | 18 +- .../src/components/headers/screen-title.tsx | 35 + packages/app/src/components/left-sidebar.tsx | 129 +- .../app/src/components/pair-link-modal.tsx | 20 +- .../components/sidebar/sidebar-header-row.tsx | 90 + .../components/sidebar/sidebar-separator.tsx | 18 + packages/app/src/components/ui/button.tsx | 2 +- .../app/src/components/welcome-screen.tsx | 120 +- .../components/desktop-permission-row.tsx | 27 +- .../desktop-permissions-section.tsx | 42 +- .../components/desktop-updates-section.tsx | 164 +- .../components/integrations-section.tsx | 63 +- .../desktop/components/pair-device-modal.tsx | 23 + .../components/pair-device-section.tsx | 3 +- packages/app/src/hooks/use-command-center.ts | 7 +- packages/app/src/hooks/use-is-local-daemon.ts | 18 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 9 +- .../app/src/screens/open-project-screen.tsx | 26 +- packages/app/src/screens/settings-screen.tsx | 2236 +++++------------ .../app/src/screens/settings/host-page.tsx | 783 ++++++ .../settings/keyboard-shortcuts-section.tsx | 44 +- .../screens/settings/providers-section.tsx | 154 ++ .../src/screens/settings/settings-section.tsx | 49 + packages/app/src/utils/host-routes.ts | 35 +- 41 files changed, 2526 insertions(+), 2317 deletions(-) create mode 100644 packages/app/e2e/settings-host-page.spec.ts create mode 100644 packages/app/e2e/settings-navigation.spec.ts delete mode 100644 packages/app/src/app/settings.tsx create mode 100644 packages/app/src/app/settings/[section].tsx create mode 100644 packages/app/src/app/settings/hosts/[serverId].tsx create mode 100644 packages/app/src/app/settings/index.tsx create mode 100644 packages/app/src/components/headers/header-icon-badge.tsx create mode 100644 packages/app/src/components/headers/screen-title.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-header-row.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-separator.tsx create mode 100644 packages/app/src/desktop/components/pair-device-modal.tsx create mode 100644 packages/app/src/screens/settings/host-page.tsx create mode 100644 packages/app/src/screens/settings/providers-section.tsx create mode 100644 packages/app/src/screens/settings/settings-section.tsx diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index e660be007..faec0bc19 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -200,16 +200,11 @@ export const gotoHome = async (page: Page) => { }; export const openSettings = async (page: Page) => { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); - } - // Navigate through the real app control so route changes stay aligned with UI behavior. const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first(); await expect(settingsButton).toBeVisible(); await settingsButton.click(); - await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`)); + await expect(page).toHaveURL(/\/settings\/general$/); }; export const setWorkingDirectory = async (page: Page, directory: string) => { diff --git a/packages/app/e2e/settings-host-page.spec.ts b/packages/app/e2e/settings-host-page.spec.ts new file mode 100644 index 000000000..cb1a1df19 --- /dev/null +++ b/packages/app/e2e/settings-host-page.spec.ts @@ -0,0 +1,181 @@ +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell, openSettings } from "./helpers/app"; +import { TEST_HOST_LABEL } from "./helpers/daemon-registry"; + +function getSeededServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +function getSeededDaemonPort(): string { + const port = process.env.E2E_DAEMON_PORT; + if (!port) { + throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup)."); + } + return port; +} + +async function openHostPage(page: Page, serverId: string) { + await page.getByTestId(`settings-host-entry-${serverId}`).click(); + await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); +} + +test.describe("Settings host page", () => { + test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({ + page, + }) => { + const serverId = getSeededServerId(); + const port = getSeededDaemonPort(); + + await gotoAppShell(page); + await openSettings(page); + await openHostPage(page, serverId); + + // Label renders as a title with a pencil edit affordance; the input is hidden until edit. + await expect(page.getByTestId("host-page-label-card")).toBeVisible(); + await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible(); + await expect(page.getByTestId("host-page-label-input")).toHaveCount(0); + await expect( + page.getByTestId("host-page-label-card").getByText(TEST_HOST_LABEL, { exact: true }), + ).toBeVisible(); + + // Desktop detail pane shows a ScreenHeader with the host label. + await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL); + + // Connections is its own section with a "Connections" heading and the seeded endpoint row. + const connectionsCard = page.getByTestId("host-page-connections-card"); + await expect(connectionsCard).toBeVisible(); + await expect(page.getByText("Connections", { exact: true })).toBeVisible(); + await expect( + connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)), + ).toBeVisible(); + + const injectMcpCard = page.getByTestId("host-page-inject-mcp-card"); + await expect(injectMcpCard).toBeVisible(); + await expect(injectMcpCard.getByRole("button", { name: "On", exact: true })).toBeVisible(); + await expect(injectMcpCard.getByRole("button", { name: "Off", exact: true })).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-providers-card")).toBeVisible(); + await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); + await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible(); + }); + + test("clicking the label pencil reveals the inline editor", async ({ page }) => { + const serverId = getSeededServerId(); + + await gotoAppShell(page); + await openSettings(page); + await openHostPage(page, serverId); + + await expect(page.getByTestId("host-page-label-input")).toHaveCount(0); + + await page.getByTestId("host-page-label-edit-button").click(); + + await expect(page.getByTestId("host-page-label-input")).toBeVisible(); + await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL); + await expect(page.getByTestId("host-page-label-save")).toBeVisible(); + }); + + test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ + page, + }) => { + const serverId = getSeededServerId(); + + await gotoAppShell(page); + await openSettings(page); + await openHostPage(page, serverId); + + // TODO: add local-daemon fixture for positive Pair/Daemon coverage. + // Pair-device now lives behind a row that only the local host sees + // (gated by useIsLocalDaemon); the seeded host is remote, so it must + // not appear. The daemon-lifecycle card is still local-host only. + await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0); + await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0); + }); + + test("settings sidebar does not expose retired top-level sections", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + + 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); + + 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(); + }); + + test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({ + page, + }) => { + const serverId = getSeededServerId(); + + await gotoAppShell(page); + await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`); + + await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); + await expect(page.getByTestId("host-page-label-card")).toBeVisible(); + await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); + }); + + test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => { + const serverId = getSeededServerId(); + + // Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the + // seeded host to the local daemon. `manageBuiltInDaemon: false` bypasses + // the desktop bootstrap flow so only the sidebar's status query runs. + await page.addInitScript((localServerId) => { + localStorage.setItem( + "@paseo:app-settings", + JSON.stringify({ theme: "auto", manageBuiltInDaemon: false, sendBehavior: "interrupt" }), + ); + (window as unknown as { paseoDesktop: unknown }).paseoDesktop = { + platform: "darwin", + invoke: async (command: string) => { + if (command === "desktop_daemon_status") { + return { + serverId: localServerId, + status: "running", + listen: null, + hostname: null, + pid: null, + home: "", + version: null, + desktopManaged: true, + error: null, + }; + } + return null; + }, + getPendingOpenProject: async () => null, + events: { on: async () => () => undefined }, + }; + }, serverId); + + await gotoAppShell(page); + await openSettings(page); + + const sidebar = page.getByTestId("settings-sidebar"); + await expect(sidebar).toBeVisible({ timeout: 15000 }); + + const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]'); + await expect(hostEntries.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(); + }); +}); diff --git a/packages/app/e2e/settings-navigation.spec.ts b/packages/app/e2e/settings-navigation.spec.ts new file mode 100644 index 000000000..301d40e75 --- /dev/null +++ b/packages/app/e2e/settings-navigation.spec.ts @@ -0,0 +1,178 @@ +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell, openSettings } from "./helpers/app"; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +async function clickSidebarSection(page: Page, label: string) { + const sidebar = page.getByTestId("settings-sidebar"); + await expect(sidebar).toBeVisible(); + await sidebar.getByRole("button", { name: label, exact: true }).click(); +} + +test.describe("Settings sidebar navigation", () => { + test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + + await clickSidebarSection(page, "Diagnostics"); + await expect(page).toHaveURL(/\/settings\/diagnostics$/); + await expect(page.getByRole("button", { name: "Play test" })).toBeVisible(); + await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics"); + + await clickSidebarSection(page, "About"); + await expect(page).toHaveURL(/\/settings\/about$/); + await expect(page.getByText("Version", { exact: true }).first()).toBeVisible(); + await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About"); + + await clickSidebarSection(page, "General"); + await expect(page).toHaveURL(/\/settings\/general$/); + await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible(); + await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General"); + }); + + test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => { + const serverId = getServerId(); + await gotoAppShell(page); + await page.goto(`/h/${encodeURIComponent(serverId)}/settings`); + await expect(page).toHaveURL( + new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`), + ); + }); + + test("the + Add host button opens the add-host method modal", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + + await page.getByTestId("settings-add-host").click(); + await expect(page.getByText("Add connection", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible(); + }); + + test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => { + await gotoAppShell(page); + await openSettings(page); + + const backRow = page.getByTestId("settings-back-to-workspace"); + await expect(backRow).toBeVisible(); + + await backRow.click(); + await expect(page).not.toHaveURL(/\/settings(\/|$)/); + }); +}); + +test.describe("Settings — compact master-detail", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + async function openCompactSettingsRoot(page: Page) { + await gotoAppShell(page); + // Wait for bootstrap to settle on a host route so storeReady is true before + // we navigate into the protected settings stack. A direct page.goto("/settings") + // on a cold load is eaten by Stack.Protected while bootstrap is still running. + await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 }); + + // Drive navigation the same way a user would: open the mobile drawer and tap + // the Settings footer icon. This preserves the in-app router state instead of + // triggering a full reload through Stack.Protected. + await page.getByRole("button", { name: "Open menu", exact: true }).first().click(); + const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first(); + await expect(sidebarSettingsButton).toBeVisible(); + await sidebarSettingsButton.click(); + + await expect(page).toHaveURL(/\/settings$/); + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + } + + test("/settings renders only the sidebar list (no section content)", async ({ page }) => { + await openCompactSettingsRoot(page); + + // Sidebar rows are present. + await expect( + page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }), + ).toBeVisible(); + await expect( + page + .getByTestId("settings-sidebar") + .getByRole("button", { name: "Diagnostics", exact: true }), + ).toBeVisible(); + await expect( + page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }), + ).toBeVisible(); + + // Section detail content is NOT rendered at the root. + await expect(page.getByText("Theme", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0); + await expect(page.getByTestId("host-page-label-card")).toHaveCount(0); + + // Root shows the menu header, not a back button. + await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0); + }); + + test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => { + await openCompactSettingsRoot(page); + + await page + .getByTestId("settings-sidebar") + .getByRole("button", { name: "Diagnostics", exact: true }) + .click(); + await expect(page).toHaveURL(/\/settings\/diagnostics$/); + await expect(page.getByRole("button", { name: "Play test" })).toBeVisible(); + // Sidebar is no longer visible — we are on a detail screen. + // (Expo Router stack keeps the previous screen in the DOM but hidden; check + // only visible instances.) + await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0); + await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible(); + }); + + test("back from a section detail returns to the /settings list", async ({ page }) => { + await openCompactSettingsRoot(page); + + await page + .getByTestId("settings-sidebar") + .getByRole("button", { name: "About", exact: true }) + .click(); + await expect(page).toHaveURL(/\/settings\/about$/); + + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect(page).toHaveURL(/\/settings$/); + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0); + }); + + test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => { + const serverId = getServerId(); + await openCompactSettingsRoot(page); + + await page.getByTestId(`settings-host-entry-${serverId}`).click(); + await expect(page).toHaveURL( + new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`), + ); + await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible(); + await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible(); + await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0); + }); + + test("back from a host detail returns to the /settings list", async ({ page }) => { + const serverId = getServerId(); + await openCompactSettingsRoot(page); + + await page.getByTestId(`settings-host-entry-${serverId}`).click(); + await expect(page).toHaveURL( + new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`), + ); + + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect(page).toHaveURL(/\/settings$/); + await expect(page.getByTestId("settings-sidebar")).toBeVisible(); + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a35cb1eb0..f317da69d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -802,7 +802,8 @@ function RootStack() { > - + + + ); } diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 0f6b8c509..fa80724e5 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -1,98 +1,9 @@ -import { useEffect } from "react"; -import { useLocalSearchParams, usePathname, useRouter } from "expo-router"; -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { useSessionStore } from "@/stores/session-store"; -import { useFormPreferences } from "@/hooks/use-form-preferences"; -import { - buildHostAgentDetailRoute, - buildHostOpenProjectRoute, - buildHostRootRoute, - buildHostWorkspaceOpenRoute, -} from "@/utils/host-routes"; -import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; -import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; - -const HOST_ROOT_REDIRECT_DELAY_MS = 300; +import { Redirect, useLocalSearchParams } from "expo-router"; +import { buildHostOpenProjectRoute } from "@/utils/host-routes"; export default function HostIndexRoute() { - return ( - - - - ); -} - -function HostIndexRouteContent() { - const router = useRouter(); - const pathname = usePathname(); const params = useLocalSearchParams<{ serverId?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; - const { isLoading: preferencesLoading } = useFormPreferences(); - const sessionAgents = useSessionStore((state) => - serverId ? state.sessions[serverId]?.agents : undefined, - ); - const sessionWorkspaces = useSessionStore((state) => - serverId ? state.sessions[serverId]?.workspaces : undefined, - ); - - useEffect(() => { - if (preferencesLoading) { - return; - } - if (!serverId) { - return; - } - const rootRoute = buildHostRootRoute(serverId); - if (pathname !== rootRoute && pathname !== `${rootRoute}/`) { - return; - } - const timer = setTimeout(() => { - if (pathname !== rootRoute && pathname !== `${rootRoute}/`) { - return; - } - - const visibleAgents = sessionAgents - ? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt) - : []; - visibleAgents.sort( - (left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(), - ); - - const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : []; - - const primaryAgent = visibleAgents[0]; - const primaryAgentWorkspaceId = resolveWorkspaceIdByExecutionDirectory({ - workspaces: sessionWorkspaces?.values(), - workspaceDirectory: primaryAgent?.cwd, - }); - if (primaryAgent && primaryAgentWorkspaceId) { - router.replace( - prepareWorkspaceTab({ - serverId, - workspaceId: primaryAgentWorkspaceId, - target: { kind: "agent", agentId: primaryAgent.id }, - }) as any, - ); - return; - } - if (primaryAgent) { - router.replace(buildHostAgentDetailRoute(serverId, primaryAgent.id) as any); - return; - } - - const primaryWorkspace = visibleWorkspaces[0]; - if (primaryWorkspace?.id?.trim()) { - router.replace( - buildHostWorkspaceOpenRoute(serverId, primaryWorkspace.id.trim(), "draft:new") as any, - ); - return; - } - - router.replace(buildHostOpenProjectRoute(serverId)); - }, HOST_ROOT_REDIRECT_DELAY_MS); - - return () => clearTimeout(timer); - }, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]); - - return null; + if (!serverId) return null; + return ; } diff --git a/packages/app/src/app/h/[serverId]/settings.tsx b/packages/app/src/app/h/[serverId]/settings.tsx index 28d1bf6a4..269e15d25 100644 --- a/packages/app/src/app/h/[serverId]/settings.tsx +++ b/packages/app/src/app/h/[serverId]/settings.tsx @@ -1,10 +1,15 @@ +import { Redirect, useLocalSearchParams } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import SettingsScreen from "@/screens/settings-screen"; +import { buildSettingsHostRoute, buildSettingsRoute } from "@/utils/host-routes"; + +export default function LegacyHostSettingsRoute() { + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId.trim() : ""; + const href = serverId.length > 0 ? buildSettingsHostRoute(serverId) : buildSettingsRoute(); -export default function HostSettingsRoute() { return ( - + ); } diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index fe647aa50..562658082 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -5,35 +5,19 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { CameraView, useCameraPermissions } from "expo-camera"; import type { BarcodeScanningResult } from "expo-camera"; -import { useHosts, useHostMutations } from "@/runtime/host-runtime"; +import { useHostMutations } from "@/runtime/host-runtime"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { connectToDaemon } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; -import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes"; +import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { isWeb } from "@/constants/platform"; +import { BackHeader } from "@/components/headers/back-header"; const styles = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.surface0, }, - header: { - paddingHorizontal: theme.spacing[6], - paddingBottom: theme.spacing[4], - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - headerTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.lg, - fontWeight: theme.fontWeight.semibold, - }, - headerButtonText: { - color: theme.colors.palette.blue[400], - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - }, body: { flex: 1, paddingHorizontal: theme.spacing[6], @@ -140,63 +124,32 @@ export default function PairScanScreen() { const router = useRouter(); const params = useLocalSearchParams<{ source?: string; - sourceServerId?: string; - targetServerId?: string; }>(); const source = typeof params.source === "string" ? params.source : "settings"; - const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null; - const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null; - const daemons = useHosts(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); const [permission, requestPermission] = useCameraPermissions(); const [isPairing, setIsPairing] = useState(false); const lastScannedRef = useRef(null); - const returnToSource = useCallback( + const navigateToPairedHost = useCallback( (serverId: string) => { if (source === "onboarding") { router.replace(buildHostRootRoute(serverId)); return; } - if (source === "editHost" && targetServerId) { - const settingsServerId = sourceServerId ?? targetServerId; - router.replace({ - pathname: buildHostSettingsRoute(settingsServerId), - params: { editHost: targetServerId }, - } as any); - return; - } - // settings (default): return to previous screen - try { - router.back(); - } catch { - const settingsServerId = sourceServerId ?? serverId; - router.replace(buildHostSettingsRoute(settingsServerId)); - } + router.replace(buildSettingsHostRoute(serverId)); }, - [router, source, sourceServerId, targetServerId], + [router, source], ); const closeToSource = useCallback(() => { - if (source === "editHost" && targetServerId) { - const settingsServerId = sourceServerId ?? targetServerId; - router.replace({ - pathname: buildHostSettingsRoute(settingsServerId), - params: { editHost: targetServerId }, - } as any); - return; - } try { router.back(); } catch { - if (sourceServerId) { - router.replace(buildHostSettingsRoute(sourceServerId)); - return; - } router.replace("/" as any); } - }, [router, source, sourceServerId, targetServerId]); + }, [router]); useEffect(() => { if (isWeb) return; @@ -220,15 +173,6 @@ export default function PairScanScreen() { const offerPayload = decodeOfferFragmentPayload(encoded); const offer = ConnectionOfferSchema.parse(offerPayload); - if (targetServerId && offer.serverId !== targetServerId) { - lastScannedRef.current = null; - Alert.alert( - "Wrong daemon", - `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`, - ); - return; - } - const { client, hostname } = await connectToDaemon( { id: "probe", @@ -242,7 +186,7 @@ export default function PairScanScreen() { const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined); - returnToSource(profile.serverId); + navigateToPairedHost(profile.serverId); } catch (error) { lastScannedRef.current = null; const message = error instanceof Error ? error.message : "Unable to pair host"; @@ -251,18 +195,13 @@ export default function PairScanScreen() { setIsPairing(false); } }, - [daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl], + [isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl], ); if (isWeb) { return ( - - Scan QR - router.back()}> - Close - - + router.back()} /> Not available on web @@ -282,12 +221,7 @@ export default function PairScanScreen() { return ( - - Scan QR - - Close - - + {!granted ? ( diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx deleted file mode 100644 index 6e425ddf0..000000000 --- a/packages/app/src/app/settings.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { useRouter } from "expo-router"; -import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; -import { useHosts } from "@/runtime/host-runtime"; -import { buildHostSettingsRoute } from "@/utils/host-routes"; - -export default function LegacySettingsRoute() { - const router = useRouter(); - const daemons = useHosts(); - - const targetServerId = useMemo(() => { - return daemons[0]?.serverId ?? null; - }, [daemons]); - - useEffect(() => { - if (!targetServerId) { - return; - } - router.replace(buildHostSettingsRoute(targetServerId)); - }, [router, targetServerId]); - - if (!targetServerId) { - return ; - } - - return null; -} diff --git a/packages/app/src/app/settings/[section].tsx b/packages/app/src/app/settings/[section].tsx new file mode 100644 index 000000000..8afddf1c0 --- /dev/null +++ b/packages/app/src/app/settings/[section].tsx @@ -0,0 +1,11 @@ +import { useLocalSearchParams } from "expo-router"; +import SettingsScreen from "@/screens/settings-screen"; +import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes"; + +export default function SettingsSectionRoute() { + const params = useLocalSearchParams<{ section?: string }>(); + const rawSection = typeof params.section === "string" ? params.section : ""; + const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general"; + + return ; +} diff --git a/packages/app/src/app/settings/hosts/[serverId].tsx b/packages/app/src/app/settings/hosts/[serverId].tsx new file mode 100644 index 000000000..02260acaa --- /dev/null +++ b/packages/app/src/app/settings/hosts/[serverId].tsx @@ -0,0 +1,14 @@ +import { useLocalSearchParams } from "expo-router"; +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() : ""; + + return ( + + + + ); +} diff --git a/packages/app/src/app/settings/index.tsx b/packages/app/src/app/settings/index.tsx new file mode 100644 index 000000000..00e212c2a --- /dev/null +++ b/packages/app/src/app/settings/index.tsx @@ -0,0 +1,14 @@ +import { Redirect } from "expo-router"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import SettingsScreen from "@/screens/settings-screen"; +import { buildSettingsSectionRoute } from "@/utils/host-routes"; + +export default function SettingsIndexRoute() { + const isCompactLayout = useIsCompactFormFactor(); + + if (!isCompactLayout) { + return ; + } + + return ; +} diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index 3f343fd00..cfbd255fc 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -70,6 +70,7 @@ export function AddHostMethodModal({ @@ -81,7 +82,12 @@ export function AddHostMethodModal({ {isNative ? ( - + Scan QR code @@ -93,6 +99,7 @@ export function AddHostMethodModal({ diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index ec2f7cf77..5877a8d02 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -132,7 +132,6 @@ function buildConnectionFailureCopy( export interface AddHostModalProps { visible: boolean; onClose: () => void; - targetServerId?: string; onCancel?: () => void; onSaved?: (result: { profile: HostProfile; @@ -142,13 +141,7 @@ export interface AddHostModalProps { }) => void; } -export function AddHostModal({ - visible, - onClose, - onCancel, - onSaved, - targetServerId, -}: AddHostModalProps) { +export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) { const { theme } = useUnistyles(); const daemons = useHosts(); const { upsertDirectConnection } = useHostMutations(); @@ -211,15 +204,6 @@ export function AddHostModal({ endpoint, }); await client.close().catch(() => undefined); - if (targetServerId && serverId !== targetServerId) { - const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`; - setErrorMessage(message); - if (!isMobile) { - Alert.alert("Wrong daemon", message); - } - return; - } - const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId); const profile = await upsertDirectConnection({ serverId, @@ -245,7 +229,7 @@ export function AddHostModal({ } finally { setIsSaving(false); } - }, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]); + }, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]); return ( {isGitCheckout ? : null} - - {title} - + {title} ); @@ -102,15 +101,6 @@ export function BranchSwitcher({ } const styles = StyleSheet.create((theme) => ({ - headerTitle: { - fontSize: theme.fontSize.base, - fontWeight: { - xs: "400", - md: "300", - }, - color: theme.colors.foreground, - flexShrink: 1, - }, branchSwitcherTrigger: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index e12cedcc4..e17f6072c 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -1,31 +1,35 @@ import type { ReactNode } from "react"; -import { Pressable, Text } from "react-native"; +import { Pressable } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; +import { ScreenTitle } from "./screen-title"; interface BackHeaderProps { title?: string; + titleAccessory?: ReactNode; rightContent?: ReactNode; onBack?: () => void; } -export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) { +export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) { const { theme } = useUnistyles(); return ( - router.back())} style={styles.backButton}> + router.back())} + style={styles.backButton} + accessibilityRole="button" + accessibilityLabel="Back" + > - {title && ( - - {title} - - )} + {title && {title}} + {titleAccessory} } right={rightContent} @@ -45,13 +49,4 @@ const styles = StyleSheet.create((theme) => ({ }, borderRadius: theme.borderRadius.lg, }, - title: { - flex: 1, - fontSize: theme.fontSize.lg, - fontWeight: { - xs: theme.fontWeight.semibold, - md: "400", - }, - color: theme.colors.foreground, - }, })); diff --git a/packages/app/src/components/headers/header-icon-badge.tsx b/packages/app/src/components/headers/header-icon-badge.tsx new file mode 100644 index 000000000..9fe5b28e4 --- /dev/null +++ b/packages/app/src/components/headers/header-icon-badge.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; +import { View } from "react-native"; +import { headerIconSlotStyle } from "./header-toggle-button"; + +/** + * Non-interactive icon slot sitting at the start of a screen header's left + * cluster. Shares the same padding + border-radius as `HeaderToggleButton` so + * decorative headers (settings sections, host detail) line up with the sidebar + * toggle across screens. + */ +export function HeaderIconBadge({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx index 93ebb21a2..e0f5b2cc6 100644 --- a/packages/app/src/components/headers/header-toggle-button.tsx +++ b/packages/app/src/components/headers/header-toggle-button.tsx @@ -51,7 +51,7 @@ export function HeaderToggleButton({ onPress={(e) => { onPress(e); }} - style={[styles.button, style]} + style={[headerIconSlotStyle.slot, style]} > {typeof children === "function" ? (state: { pressed: boolean; hovered?: boolean }) => @@ -68,14 +68,17 @@ export function HeaderToggleButton({ ); } -const styles = StyleSheet.create((theme) => ({ - button: { +export const headerIconSlotStyle = StyleSheet.create((theme) => ({ + slot: { padding: { xs: theme.spacing[3], md: theme.spacing[2], }, borderRadius: theme.borderRadius.lg, }, +})); + +const styles = StyleSheet.create((theme) => ({ tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 09234adc7..7074dbb2b 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -1,8 +1,9 @@ import type { ReactNode } from "react"; -import { Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; +import { ScreenTitle } from "./screen-title"; import { HeaderToggleButton } from "./header-toggle-button"; import { usePanelStore } from "@/stores/panel-store"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -83,11 +84,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) left={ <> - {title && ( - - {title} - - )} + {title && {title}} } right={rightContent} @@ -101,15 +98,6 @@ const styles = StyleSheet.create((theme) => ({ left: { gap: theme.spacing[2], }, - title: { - flex: 1, - fontSize: theme.fontSize.base, - fontWeight: { - xs: "400", - md: "300", - }, - color: theme.colors.foreground, - }, mobileMenuIcon: { width: MOBILE_MENU_LINE_WIDTH, height: 12, diff --git a/packages/app/src/components/headers/screen-title.tsx b/packages/app/src/components/headers/screen-title.tsx new file mode 100644 index 000000000..57a60f60d --- /dev/null +++ b/packages/app/src/components/headers/screen-title.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; +import { Text, type StyleProp, type TextStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +interface ScreenTitleProps { + children: ReactNode; + numberOfLines?: number; + testID?: string; + style?: StyleProp; +} + +/** + * Canonical screen title for use inside `ScreenHeader`. One typography, one + * color, responsive weight. Leading icons are siblings (HeaderToggleButton, + * HeaderIconBadge) — never nested inside this component. + */ +export function ScreenTitle({ children, numberOfLines = 1, testID, style }: ScreenTitleProps) { + return ( + + {children} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + text: { + flexShrink: 1, + fontSize: theme.fontSize.base, + fontWeight: { + xs: "400", + md: "300", + }, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 7ec33e7d0..688cda0ea 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -33,6 +33,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { router, usePathname } from "expo-router"; import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; @@ -46,14 +47,10 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; import { formatConnectionStatus } from "@/utils/daemons"; -import { - HEADER_INNER_HEIGHT, - HEADER_INNER_HEIGHT_MOBILE, - useIsCompactFormFactor, -} from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { buildHostSessionsRoute, - buildHostSettingsRoute, + buildSettingsRoute, mapPathnameToServer, parseServerIdFromPathname, } from "@/utils/host-routes"; @@ -220,19 +217,13 @@ export const LeftSidebar = memo(function LeftSidebar({ }, [openProjectPicker]); const handleSettingsMobile = useCallback(() => { - if (!activeServerId) { - return; - } closeToAgent(); - router.push(buildHostSettingsRoute(activeServerId)); - }, [activeServerId, closeToAgent]); + router.push(buildSettingsRoute()); + }, [closeToAgent]); const handleSettingsDesktop = useCallback(() => { - if (!activeServerId) { - return; - } - router.push(buildHostSettingsRoute(activeServerId)); - }, [activeServerId]); + router.push(buildSettingsRoute()); + }, []); const handleViewMoreNavigate = useCallback(() => { if (!activeServerId) { @@ -328,44 +319,6 @@ function HostSwitchOption({ ); } -function SessionsButton({ onPress }: { onPress: () => void }) { - const { theme } = useUnistyles(); - const pathname = usePathname(); - const isActive = pathname.includes("/sessions"); - - return ( - [ - styles.newAgentButton, - hovered && styles.newAgentButtonHovered, - isActive && styles.newAgentButtonActive, - ]} - testID="sidebar-sessions" - accessible - accessibilityRole="button" - accessibilityLabel="Sessions" - onPress={onPress} - > - {({ hovered }) => ( - <> - - - Sessions - - - )} - - ); -} - function MobileSidebar({ theme, activeServerId, @@ -394,6 +347,8 @@ function MobileSidebar({ handleViewMoreNavigate, }: MobileSidebarProps) { const newAgentKeys = useShortcutKeys("new-agent"); + const pathname = usePathname(); + const isSessionsActive = pathname.includes("/sessions"); const { translateX, backdropOpacity, @@ -544,11 +499,13 @@ function MobileSidebar({ pointerEvents="auto" > - - - - - + {isInitialLoad ? ( @@ -676,6 +633,8 @@ function DesktopSidebar({ handleViewMore, }: DesktopSidebarProps) { const newAgentKeys = useShortcutKeys("new-agent"); + const pathname = usePathname(); + const isSessionsActive = pathname.includes("/sessions"); const padding = useWindowControlsPadding("sidebar"); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth); @@ -732,11 +691,13 @@ function DesktopSidebar({ {padding.top > 0 ? : null} - - - - - + {isInitialLoad ? ( @@ -881,46 +842,6 @@ const styles = StyleSheet.create((theme) => ({ sidebarDragArea: { position: "relative", }, - sidebarHeader: { - height: { - xs: HEADER_INNER_HEIGHT_MOBILE, - md: HEADER_INNER_HEIGHT, - }, - paddingHorizontal: theme.spacing[2], - justifyContent: "center", - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - userSelect: "none", - }, - sidebarHeaderRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[2], - }, - newAgentButton: { - flex: 1, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - newAgentButtonText: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - }, - newAgentButtonTextHovered: { - color: theme.colors.foreground, - }, - newAgentButtonHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, - newAgentButtonActive: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, hostTrigger: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index e287e1596..8b7e4f43e 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -47,7 +47,6 @@ const styles = StyleSheet.create((theme) => ({ export interface PairLinkModalProps { visible: boolean; onClose: () => void; - targetServerId?: string; onCancel?: () => void; onSaved?: (result: { profile: HostProfile; @@ -57,13 +56,7 @@ export interface PairLinkModalProps { }) => void; } -export function PairLinkModal({ - visible, - onClose, - onCancel, - onSaved, - targetServerId, -}: PairLinkModalProps) { +export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) { const { theme } = useUnistyles(); const daemons = useHosts(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); @@ -128,15 +121,6 @@ export function PairLinkModal({ return; } - if (targetServerId && parsedOffer.serverId !== targetServerId) { - const message = `That pairing link belongs to ${parsedOffer.serverId}, not ${targetServerId}.`; - setErrorMessage(message); - if (!isMobile) { - Alert.alert("Wrong daemon", message); - } - return; - } - try { setIsSaving(true); setErrorMessage(""); @@ -165,7 +149,7 @@ export function PairLinkModal({ } finally { setIsSaving(false); } - }, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDaemonFromOfferUrl]); + }, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]); return ( void; + isActive?: boolean; + testID?: string; + nativeID?: string; + accessibilityLabel?: string; +} + +/** + * Top-of-sidebar header row: a sidebar-height pressable with an icon + label + * and a full-width border separator beneath. Used as the first element of a + * sidebar (workspace "Sessions", settings "Back to workspace"). Owns its own + * separator line so both sidebars converge on the same edge and padding. + */ +export function SidebarHeaderRow({ + icon: Icon, + label, + onPress, + isActive = false, + testID, + nativeID, + accessibilityLabel, +}: SidebarHeaderRowProps) { + const { theme } = useUnistyles(); + + return ( + + [styles.button, (hovered || isActive) && styles.buttonHovered]} + > + {({ hovered }) => { + const isHighlighted = hovered || isActive; + const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted; + return ( + <> + + {label} + + ); + }} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + height: { + xs: HEADER_INNER_HEIGHT_MOBILE, + md: HEADER_INNER_HEIGHT, + }, + paddingHorizontal: theme.spacing[2], + justifyContent: "center", + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + userSelect: "none", + }, + button: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + }, + buttonHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + label: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + labelHighlighted: { + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-separator.tsx b/packages/app/src/components/sidebar/sidebar-separator.tsx new file mode 100644 index 000000000..864e56361 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-separator.tsx @@ -0,0 +1,18 @@ +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * Edge-to-edge separator line for sidebars. Must render outside any horizontally + * padded container so the line runs the full sidebar width — matching the + * separator beneath the workspace sidebar's "New agent" header. + */ +export function SidebarSeparator() { + return ; +} + +const styles = StyleSheet.create((theme) => ({ + line: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 195b95d58..7da8dbfb5 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -181,7 +181,7 @@ export function Button({ ]} > {renderIcon()} - {children} + {children != null ? {children} : null} ); } diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index d74d87d35..af5db1baf 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; import { @@ -13,6 +13,7 @@ import { } from "@/runtime/host-runtime"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; +import { Button } from "@/components/ui/button"; import { resolveAppVersion } from "@/utils/app-version"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { buildHostRootRoute } from "@/utils/host-routes"; @@ -30,6 +31,10 @@ type WelcomeAction = { }; const styles = StyleSheet.create((theme) => ({ + scrollView: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, container: { flexGrow: 1, backgroundColor: theme.colors.surface0, @@ -47,14 +52,17 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.xl, fontWeight: theme.fontWeight.medium, - marginBottom: theme.spacing[3], textAlign: "center", }, subtitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: theme.fontSize.sm, textAlign: "center", - marginBottom: theme.spacing[8], + }, + copyBlock: { + alignItems: "center", + gap: theme.spacing[2], + marginBottom: theme.spacing[12], }, actions: { width: "100%", @@ -117,19 +125,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, fontSize: theme.fontSize.sm, }, - setupHint: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - textAlign: "center", - marginBottom: theme.spacing[6], - lineHeight: theme.fontSize.sm * 1.5, - }, setupLink: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6, - marginBottom: theme.spacing[6], }, setupLinkText: { color: theme.colors.accent, @@ -142,6 +142,10 @@ const styles = StyleSheet.create((theme) => ({ textAlign: "center", marginTop: theme.spacing[6], }, + settingsButton: { + alignSelf: "center", + marginTop: theme.spacing[6], + }, })); function useAnyHostOnline(serverIds: string[]): string | null { @@ -233,6 +237,35 @@ export interface WelcomeScreenProps { export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const { theme } = useUnistyles(); + useEffect(() => { + const probe = (tag: string) => { + // eslint-disable-next-line no-console + console.log(`[trace-theme] ${tag}`, { + runtimeName: UnistylesRuntime.themeName, + hookSurface0: theme.colors.surface0, + hookForeground: theme.colors.foreground, + hookSurface2: theme.colors.surface2, + stylesContainerBg: (styles.container as { backgroundColor?: string } | undefined) + ?.backgroundColor, + stylesTitleColor: (styles.title as { color?: string } | undefined)?.color, + stylesActionButtonBg: (styles.actionButton as { backgroundColor?: string } | undefined) + ?.backgroundColor, + stylesActionButtonPrimaryBg: ( + styles.actionButtonPrimary as { backgroundColor?: string } | undefined + )?.backgroundColor, + stylesSubtitleColor: (styles.subtitle as { color?: string } | undefined)?.color, + }); + }; + probe("poll-t0"); + const t1 = setTimeout(() => probe("poll-t1s"), 1000); + const t2 = setTimeout(() => probe("poll-t3s"), 3000); + const t3 = setTimeout(() => probe("poll-t6s"), 6000); + return () => { + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; + }, [theme]); const insets = useSafeAreaInsets(); const router = useRouter(); const appVersion = resolveAppVersion(); @@ -243,13 +276,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); useEffect(() => { - const currentPathname = typeof window === "undefined" ? null : window.location.pathname || null; - if (currentPathname && currentPathname !== "/welcome") { - return; - } - if (!anyOnlineServerId) { - return; - } + if (!anyOnlineServerId) return; router.replace(buildHostRootRoute(anyOnlineServerId)); }, [anyOnlineServerId, router]); @@ -310,32 +337,38 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { return ( - Welcome to Paseo - - {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} - - - {!showHostList && isNative && ( - <> - - You need the Paseo desktop app or server running on your computer first. - - openExternalUrl("https://paseo.sh")}> - Get started at paseo.sh - - - - )} + + Welcome to Paseo + {showHostList ? ( + Connecting to your hosts… + ) : ( + <> + Connect your computer to get started + {isNative ? ( + openExternalUrl("https://paseo.sh")} + > + paseo.sh + + + ) : null} + + )} + {actions.map((action) => { @@ -366,6 +399,17 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { ))} )} + + {appVersionText} diff --git a/packages/app/src/desktop/components/desktop-permission-row.tsx b/packages/app/src/desktop/components/desktop-permission-row.tsx index 6622a5b41..a6036818b 100644 --- a/packages/app/src/desktop/components/desktop-permission-row.tsx +++ b/packages/app/src/desktop/components/desktop-permission-row.tsx @@ -2,6 +2,7 @@ import { View, Text } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check } from "lucide-react-native"; import { Button } from "@/components/ui/button"; +import { settingsStyles } from "@/styles/settings"; import type { DesktopPermissionStatus } from "@/desktop/permissions/desktop-permissions"; export interface DesktopPermissionRowProps { @@ -38,9 +39,9 @@ export function DesktopPermissionRow({ state !== "not-granted"; return ( - - - {title} + + + {title} {isGranted ? ( @@ -74,25 +75,6 @@ export function DesktopPermissionRow({ } const styles = StyleSheet.create((theme) => ({ - audioRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - audioRowBorder: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - audioRowContent: { - flex: 1, - marginRight: theme.spacing[3], - }, - audioRowTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, permissionRowActions: { alignItems: "flex-end", gap: theme.spacing[1], @@ -117,7 +99,6 @@ const styles = StyleSheet.create((theme) => ({ }, permissionStatusText: { fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, permissionDetailText: { diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx index 419032e0e..e11d35041 100644 --- a/packages/app/src/desktop/components/desktop-permissions-section.tsx +++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row"; import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions"; import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/screens/settings/settings-section"; export function DesktopPermissionsSection() { const { theme } = useUnistyles(); @@ -27,23 +28,23 @@ export function DesktopPermissionsSection() { const isBusy = isRefreshing || requestingPermission !== null; const notificationsGranted = snapshot?.notifications.state === "granted"; + const refreshButton = ( + + ); + return ( - - - Desktop Permissions - - + - + ); } const styles = StyleSheet.create((theme) => ({ - permissionSectionHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[2], - marginBottom: theme.spacing[3], - }, errorText: { fontSize: theme.fontSize.xs, paddingHorizontal: theme.spacing[4], diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index a6c77db19..60d2401d2 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/screens/settings/settings-section"; import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -18,15 +19,12 @@ import { stopDesktopDaemon, } from "@/desktop/daemon/desktop-daemon"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; +import { resolveAppVersion } from "@/utils/app-version"; -export interface LocalDaemonSectionProps { - appVersion: string | null; - showLifecycleControls: boolean; -} - -export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalDaemonSectionProps) { +export function LocalDaemonSection() { const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); + const appVersion = resolveAppVersion(); const { settings, updateSettings } = useAppSettings(); const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus(); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); @@ -236,22 +234,26 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD return null; } + const advancedSettingsButton = ( + + ); + return ( - - - Built-in daemon - - + {isLoading ? ( @@ -271,61 +273,57 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD {daemonStatusDetailText} - {showLifecycleControls ? ( - <> - - - Daemon management - - {isDaemonManagementPaused - ? "Paused. The built-in daemon stays stopped until you start it again." - : "Enabled. Paseo can manage the built-in daemon from the desktop app."} - - - - - - - {daemonActionLabel} - {daemonActionMessage} - {statusMessage ? {statusMessage} : null} - - - - - ) : null} + + + Daemon management + + {isDaemonManagementPaused + ? "Paused. The built-in daemon stays stopped until you start it again." + : "Enabled. Paseo can manage the built-in daemon from the desktop app."} + + + + + + + {daemonActionLabel} + {daemonActionMessage} + {statusMessage ? {statusMessage} : null} + + + Log file @@ -423,7 +421,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD - + ); } @@ -448,7 +446,6 @@ const styles = StyleSheet.create((theme) => ({ valueText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, }, valueSubtext: { color: theme.colors.foregroundMuted, @@ -482,17 +479,6 @@ const styles = StyleSheet.create((theme) => ({ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", lineHeight: 18, }, - codeBlock: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - lineHeight: 18, - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface0, - padding: theme.spacing[3], - }, modalActions: { flexDirection: "row", justifyContent: "flex-end", diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index ea1b8b909..e5ec00812 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -4,6 +4,7 @@ import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native"; import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/screens/settings/settings-section"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; import { @@ -79,39 +80,35 @@ export function IntegrationsSection() { return null; } + const trailing = ( + + + + + ); + return ( - - - Integrations - - - - - + @@ -166,7 +163,7 @@ export function IntegrationsSection() { )} - + ); } diff --git a/packages/app/src/desktop/components/pair-device-modal.tsx b/packages/app/src/desktop/components/pair-device-modal.tsx new file mode 100644 index 000000000..4a774b2c4 --- /dev/null +++ b/packages/app/src/desktop/components/pair-device-modal.tsx @@ -0,0 +1,23 @@ +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { PairDeviceSection } from "@/desktop/components/pair-device-section"; + +export interface PairDeviceModalProps { + visible: boolean; + onClose: () => void; + testID?: string; +} + +export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) { + return ( + + + + ); +} diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index ee63a78c6..36ed801aa 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -45,8 +45,7 @@ export function PairDeviceSection() { if (!showSection) return null; return ( - - Pair device + {pairingQuery.isPending ? ( diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index dae233ed5..6f2bd9a00 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -14,7 +14,7 @@ import { } from "@/utils/command-center-focus-restore"; import { buildHostAgentDetailRoute, - buildHostSettingsRoute, + buildSettingsRoute, parseServerIdFromPathname, } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -170,9 +170,8 @@ export function useCommandCenter() { }, [agents, open, query]); const settingsRoute = useMemo(() => { - const serverIdFromPath = activeServerId; - return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/"; - }, [activeServerId]); + return buildSettingsRoute(); + }, []); const actionItems = useMemo(() => { if (!open) { diff --git a/packages/app/src/hooks/use-is-local-daemon.ts b/packages/app/src/hooks/use-is-local-daemon.ts index 2557779a3..76601739a 100644 --- a/packages/app/src/hooks/use-is-local-daemon.ts +++ b/packages/app/src/hooks/use-is-local-daemon.ts @@ -15,8 +15,7 @@ async function loadDesktopDaemonServerId(): Promise }; } -export function useIsLocalDaemon(serverId: string): boolean { - const normalizedServerId = serverId.trim(); +export function useLocalDaemonServerId(): string | null { const isDesktopApp = shouldUseDesktopDaemon(); const query = useQuery({ @@ -32,9 +31,20 @@ export function useIsLocalDaemon(serverId: string): boolean { retry: false, }); - if (!isDesktopApp || normalizedServerId.length === 0) { + if (!isDesktopApp) { + return null; + } + + return query.data?.serverId ?? null; +} + +export function useIsLocalDaemon(serverId: string): boolean { + const normalizedServerId = serverId.trim(); + const localServerId = useLocalDaemonServerId(); + + if (localServerId === null || normalizedServerId.length === 0) { return false; } - return query.data?.serverId === normalizedServerId; + return localServerId === normalizedServerId; } diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index ac82e7649..ddb203ee8 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -5,7 +5,7 @@ import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { - buildHostSettingsRoute, + buildSettingsRoute, parseHostAgentRouteFromPathname, parseServerIdFromPathname, parseHostWorkspaceRouteFromPathname, @@ -245,14 +245,11 @@ export function useKeyboardShortcuts({ toggleAgentList(); return true; case "settings.toggle": - if (pathname.endsWith("/settings")) { + if (pathname.startsWith("/settings")) { router.back(); return true; } - if (!activeServerId) { - return false; - } - router.push(buildHostSettingsRoute(activeServerId)); + router.push(buildSettingsRoute()); return true; case "sidebar.toggle.both": if (toggleBothSidebars) { diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index ee68eadc1..a09209d7f 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -1,7 +1,7 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { View, Text } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { FolderOpen } from "lucide-react-native"; +import { FolderOpen, Smartphone } from "lucide-react-native"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { Button } from "@/components/ui/button"; import { MenuHeader } from "@/components/headers/menu-header"; @@ -15,12 +15,16 @@ import { HEADER_TOP_PADDING_MOBILE, } from "@/constants/layout"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; export function OpenProjectScreen({ serverId }: { serverId: string }) { const openAgentList = usePanelStore((s) => s.openAgentList); const openProjectPicker = useOpenProjectPicker(serverId); const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false); const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0); + const isLocalDaemon = useIsLocalDaemon(serverId); + const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false); const isCompactLayout = useIsCompactFormFactor(); @@ -55,8 +59,23 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { > Add a project + {isLocalDaemon ? ( + + ) : null} + setIsPairDeviceOpen(false)} + testID="open-project-pair-device-modal" + /> ); } @@ -88,6 +107,9 @@ const styles = StyleSheet.create((theme) => ({ }, cta: { marginTop: theme.spacing[12], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], }, heading: { color: theme.colors.foreground, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 0b3e0e047..66dfcb36f 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1,44 +1,39 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import type { MutableRefObject, ComponentType } from "react"; -import { View, Text, ScrollView, Alert, Pressable } from "react-native"; -import { router, useLocalSearchParams } from "expo-router"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import type { ComponentType, ReactNode } from "react"; +import { Alert, Pressable, ScrollView, Text, View } from "react-native"; +import { useRouter } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Buffer } from "buffer"; import { + ArrowLeft, Sun, Moon, Monitor, ChevronDown, - Globe, Settings, - RotateCw, - Trash2, Server, Keyboard, Stethoscope, Info, Shield, Puzzle, - Blocks, - Smartphone, + Plus, } from "lucide-react-native"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; +import { ScreenTitle } from "@/components/headers/screen-title"; +import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; +import { SettingsSection } from "@/screens/settings/settings-section"; import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings"; -import { THEME_SWATCHES, type ThemeName } from "@/styles/theme"; -import type { HostProfile, HostConnection } from "@/types/host-connection"; -import { useHosts, useHostMutations } from "@/runtime/host-runtime"; -import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; +import { THEME_SWATCHES } from "@/styles/theme"; +import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; +import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; +import { useWindowControlsPadding } from "@/utils/desktop-window"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { MenuHeader } from "@/components/headers/menu-header"; -import { useSessionStore } from "@/stores/session-store"; -import { - getHostRuntimeStore, - isHostRuntimeConnected, - useHostRuntimeClient, - useHostRuntimeIsConnected, - useHostRuntimeSnapshot, -} from "@/runtime/host-runtime"; +import { BackHeader } from "@/components/headers/back-header"; +import { ScreenHeader } from "@/components/headers/screen-header"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { PairLinkModal } from "@/components/pair-link-modal"; @@ -52,11 +47,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; -import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; -import { PairDeviceSection } from "@/desktop/components/pair-device-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -64,311 +56,45 @@ 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 { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; -import { useDaemonConfig } from "@/hooks/use-daemon-config"; -import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { HostPage, HostRenameButton } from "@/screens/settings/host-page"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { getProviderIcon } from "@/components/provider-icons"; -import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; -import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; -import { StatusBadge } from "@/components/ui/status-badge"; -import { buildProviderDefinitions } from "@/utils/provider-definitions"; -import { isWeb } from "@/constants/platform"; +import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; +import { + buildHostOpenProjectRoute, + buildSettingsHostRoute, + buildSettingsSectionRoute, + type SettingsSectionSlug, +} from "@/utils/host-routes"; // --------------------------------------------------------------------------- -// Section definitions +// View model // --------------------------------------------------------------------------- -type SettingsSectionId = - | "hosts" - | "general" - | "shortcuts" - | "integrations" - | "providers" - | "diagnostics" - | "about" - | "permissions" - | "daemon" - | "pair-device"; +export type SettingsView = + | { kind: "root" } + | { kind: "section"; section: SettingsSectionSlug } + | { kind: "host"; serverId: string }; -interface SettingsSectionDef { - id: SettingsSectionId; +interface SidebarSectionItem { + id: SettingsSectionSlug; label: string; icon: ComponentType<{ size: number; color: string }>; + desktopOnly?: boolean; } -function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] { - const sections: SettingsSectionDef[] = [ - { id: "hosts", label: "Hosts", icon: Server }, - { id: "general", label: "General", icon: Settings }, - { id: "permissions", label: "Permissions", icon: Shield }, - ]; - - if (context.isDesktopApp) { - sections.push( - { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, - { id: "integrations", label: "Integrations", icon: Puzzle }, - { id: "pair-device", label: "Pair device", icon: Smartphone }, - { id: "daemon", label: "Daemon", icon: Settings }, - { id: "providers", label: "Providers", icon: Blocks }, - ); - } - - sections.push( - { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, - { id: "about", label: "About", icon: Info }, - ); - - return sections; -} +const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ + { id: "general", label: "General", icon: Settings }, + { id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true }, + { id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true }, + { id: "permissions", label: "Permissions", icon: Shield, desktopOnly: true }, + { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, + { id: "about", label: "About", icon: Info }, +]; // --------------------------------------------------------------------------- -// Helpers +// Theme helpers (General section) // --------------------------------------------------------------------------- -const delay = (ms: number) => - new Promise((resolve) => { - const timeout = setTimeout(() => { - clearTimeout(timeout); - resolve(); - }, ms); - }); - -function formatHostConnectionLabel(connection: HostConnection): string { - if (connection.type === "relay") { - return `Relay (${connection.relayEndpoint})`; - } - if (connection.type === "directSocket") { - return `Local (${connection.path})`; - } - if (connection.type === "directPipe") { - return `Local (${connection.path})`; - } - return `TCP (${connection.endpoint})`; -} - -function formatActiveConnectionBadge(input: { - activeConnection: { type: HostConnection["type"]; display: string } | null; - theme: ReturnType["theme"]; -}) { - const { activeConnection, theme } = input; - if (!activeConnection) { - return null; - } - if (activeConnection.type === "relay") { - return { - icon: , - text: "Relay", - }; - } - if (activeConnection.type === "directSocket") { - return { - icon: , - text: "Local", - }; - } - if (activeConnection.type === "directPipe") { - return { - icon: , - text: "Local", - }; - } - return { - icon: , - text: activeConnection.display, - }; -} - -function formatDaemonVersionBadge(version: string | null): string | null { - const daemonVersion = version?.trim(); - if (!daemonVersion) { - return null; - } - if (daemonVersion.startsWith("v")) { - return daemonVersion; - } - return `v${daemonVersion}`; -} - -// --------------------------------------------------------------------------- -// Section content components -// --------------------------------------------------------------------------- - -interface HostsSectionProps { - daemons: HostProfile[]; - settings: AppSettings; - routeServerId: string; - theme: ReturnType["theme"]; - handleEditDaemon: (profile: HostProfile) => void; - setIsAddHostMethodVisible: (visible: boolean) => void; - isAddHostMethodVisible: boolean; - isDirectHostVisible: boolean; - isPasteLinkVisible: boolean; - closeAddConnectionFlow: () => void; - goBackToAddConnectionMethods: () => void; - setIsDirectHostVisible: (visible: boolean) => void; - setIsPasteLinkVisible: (visible: boolean) => void; - pendingRemoveHost: HostProfile | null; - setPendingRemoveHost: (host: HostProfile | null) => void; - isRemovingHost: boolean; - setIsRemovingHost: (removing: boolean) => void; - removeHost: (serverId: string) => Promise; - editingDaemonLive: HostProfile | null; - isSavingEdit: boolean; - handleCloseEditDaemon: () => void; - handleSaveEditDaemon: (label: string) => Promise; - handleRemoveConnection: (serverId: string, connectionId: string) => Promise; - handleRemoveDaemon: (profile: HostProfile) => void; - restartConfirmationMessage: string; - waitForCondition: ( - predicate: () => boolean, - timeoutMs: number, - intervalMs?: number, - ) => Promise; - isMountedRef: MutableRefObject; -} - -function HostsSection(props: HostsSectionProps) { - const { theme } = useUnistyles(); - - return ( - <> - - Hosts - - {props.daemons.length === 0 ? ( - - No hosts configured - - ) : ( - props.daemons.map((daemon) => { - return ( - - ); - }) - )} - - - - - { - props.setIsAddHostMethodVisible(false); - props.setIsDirectHostVisible(true); - }} - onPasteLink={() => { - props.setIsAddHostMethodVisible(false); - props.setIsPasteLinkVisible(true); - }} - onScanQr={() => { - const sourceServerId = props.routeServerId || undefined; - props.closeAddConnectionFlow(); - router.push({ - pathname: "/pair-scan", - params: { source: "settings", sourceServerId }, - }); - }} - /> - - - - - - {props.pendingRemoveHost ? ( - { - if (props.isRemovingHost) return; - props.setPendingRemoveHost(null); - }} - testID="remove-host-confirm-modal" - > - - Remove {props.pendingRemoveHost.label}? This will delete its saved connections. - - - - - - - ) : null} - - void props.handleSaveEditDaemon(label)} - onRemoveConnection={props.handleRemoveConnection} - onRemoveHost={props.handleRemoveDaemon} - restartConfirmationMessage={props.restartConfirmationMessage} - waitForCondition={props.waitForCondition} - isScreenMountedRef={props.isMountedRef} - /> - - ); -} - -interface GeneralSectionProps { - routeServerId: string; - settings: AppSettings; - handleThemeChange: (theme: AppSettings["theme"]) => void; - handleSendBehaviorChange: (behavior: SendBehavior) => void; -} - function ThemeIcon({ theme, size, @@ -415,25 +141,31 @@ const THEME_LABELS: Record = { auto: "System", }; +// --------------------------------------------------------------------------- +// Section components +// --------------------------------------------------------------------------- + +interface GeneralSectionProps { + settings: AppSettings; + handleThemeChange: (theme: AppSettings["theme"]) => void; + handleSendBehaviorChange: (behavior: SendBehavior) => void; +} + function GeneralSection({ - routeServerId, settings, handleThemeChange, handleSendBehaviorChange, }: GeneralSectionProps) { const { theme } = useUnistyles(); - const isConnected = useHostRuntimeIsConnected(routeServerId); - const { config, patchConfig } = useDaemonConfig(routeServerId); const iconSize = theme.iconSize.md; const iconColor = theme.colors.foregroundMuted; return ( - - General - - - - Theme + + + + + Theme - - - Default send - + + + Default send + What happens when you press Enter while the agent is running @@ -485,158 +217,8 @@ function GeneralSection({ ]} /> - {routeServerId.length > 0 && isConnected ? ( - - - Inject Paseo tools - - Automatically inject Paseo MCP tools into new agents - - - { - void patchConfig({ - mcp: { - injectIntoAgents: value === "on", - }, - }); - }} - options={[ - { value: "on", label: "On" }, - { value: "off", label: "Off" }, - ]} - /> - - ) : null} - - ); -} - -interface ProvidersSectionProps { - routeServerId: string; -} - -function ProvidersSection({ routeServerId }: ProvidersSectionProps) { - const { theme } = useUnistyles(); - const isConnected = useHostRuntimeIsConnected(routeServerId); - const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(routeServerId); - const [diagnosticProvider, setDiagnosticProvider] = useState(null); - const providerDefinitions = buildProviderDefinitions(entries); - const providerRefreshInFlight = - isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false); - - const hasServer = routeServerId.length > 0; - - return ( - <> - - - Providers - {hasServer && isConnected ? ( - { - void refresh(); - }} - disabled={providerRefreshInFlight} - hitSlop={8} - style={({ hovered, pressed }) => [ - settingsStyles.sectionHeaderLink, - styles.providerRefreshButton, - (hovered || pressed) && styles.providerRefreshButtonHovered, - providerRefreshInFlight ? styles.providerRefreshButtonDisabled : null, - ]} - accessibilityRole="button" - accessibilityLabel="Refresh providers" - > - - - ) : null} - - {!hasServer || !isConnected ? ( - - Connect to a host to see providers - - ) : isLoading ? ( - - Loading... - - ) : ( - - {providerDefinitions.map((def) => { - const entry = entries?.find((e) => e.provider === def.id); - const status = entry?.status ?? "unavailable"; - const ProviderIcon = getProviderIcon(def.id); - const providerError = - status === "error" && - typeof entry?.error === "string" && - entry.error.trim().length > 0 - ? entry.error.trim() - : null; - - const modelCount = entry?.models?.length ?? 0; - - return ( - setDiagnosticProvider(def.id)} - accessibilityRole="button" - > - - - - {def.label} - - {providerError ? ( - - {providerError} - - ) : null} - {status === "ready" && modelCount > 0 ? ( - - {modelCount === 1 ? "1 model" : `${modelCount} models`} - - ) : null} - - - - ); - })} - - )} - - - {diagnosticProvider ? ( - setDiagnosticProvider(null)} - serverId={routeServerId} - /> - ) : null} - + ); } @@ -654,14 +236,13 @@ function DiagnosticsSection({ handlePlaybackTest, }: DiagnosticsSectionProps) { return ( - - Diagnostics - - - - Test audio + + + + + Test audio {playbackTestResult ? ( - {playbackTestResult} + {playbackTestResult} ) : null} - - - - - - - {/* Remove connection confirmation */} - {pendingRemoveConnection ? ( - { - if (isRemovingConnection) return; - setPendingRemoveConnection(null); - }} - testID="remove-connection-confirm-modal" - > - - Remove {pendingRemoveConnection.title}? This cannot be undone. - - - - - - - ) : null} + { + setIsAddHostMethodVisible(false); + setIsDirectHostVisible(true); + }} + onPasteLink={() => { + setIsAddHostMethodVisible(false); + setIsPasteLinkVisible(true); + }} + onScanQr={handleScanQr} + /> + + ); -} -// --------------------------------------------------------------------------- -// ConnectionRow (unchanged) -// --------------------------------------------------------------------------- - -function ConnectionRow({ - connection, - latencyMs, - latencyLoading, - latencyError, - onRemove, -}: { - connection: HostConnection; - latencyMs: number | null | undefined; - latencyLoading: boolean; - latencyError: boolean; - onRemove: () => void; -}) { - const { theme } = useUnistyles(); - const title = formatHostConnectionLabel(connection); - - const latencyText = (() => { - if (latencyLoading) return "..."; - if (latencyError) return "Timeout"; - if (latencyMs != null) return `${latencyMs}ms`; - return "\u2014"; - })(); - - const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted; - - return ( - - {title} - {latencyText} - - - ); -} - -// --------------------------------------------------------------------------- -// DaemonCard (unchanged) -// --------------------------------------------------------------------------- - -interface DaemonCardProps { - daemon: HostProfile; - onOpenSettings: (daemon: HostProfile) => void; -} - -function DaemonCard({ daemon, onOpenSettings }: DaemonCardProps) { - const { theme } = useUnistyles(); - const snapshot = useHostRuntimeSnapshot(daemon.serverId); - const connectionStatus = snapshot?.connectionStatus ?? "connecting"; - const activeConnection = snapshot?.activeConnection ?? null; - const lastError = snapshot?.lastError ?? null; - const daemonVersion = useSessionStore( - useCallback( - (state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null, - [daemon.serverId], - ), - ); - const statusLabel = formatConnectionStatus(connectionStatus); - const statusTone = getConnectionStatusTone(connectionStatus); - const statusColor = - statusTone === "success" - ? theme.colors.palette.green[400] - : statusTone === "warning" - ? theme.colors.palette.amber[500] - : statusTone === "error" - ? theme.colors.destructive - : theme.colors.foregroundMuted; - const badgeText = statusLabel; - const connectionError = - typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; - const statusPillBg = - statusTone === "success" - ? "rgba(74, 222, 128, 0.1)" - : statusTone === "warning" - ? "rgba(245, 158, 11, 0.1)" - : statusTone === "error" - ? "rgba(248, 113, 113, 0.1)" - : "rgba(161, 161, 170, 0.1)"; - const connectionBadge = (() => { - return formatActiveConnectionBadge({ - activeConnection, - theme, - }); - })(); - const versionBadgeText = formatDaemonVersionBadge(daemonVersion); - - return ( - - - - - {daemon.label} - - - - - {isWeb ? ( - {badgeText} - ) : null} - - - {connectionBadge ? ( - - {connectionBadge.icon} - {isWeb ? ( - - {connectionBadge.text} - - ) : null} - - ) : null} - {versionBadgeText ? ( - - - {versionBadgeText} - - - ) : null} - - onOpenSettings(daemon)} - testID={`daemon-card-settings-${daemon.serverId}`} - accessibilityLabel={`Open settings for ${daemon.label}`} - accessibilityRole="button" - > - {({ hovered = false, pressed = false }) => ( - - )} - - - - {connectionError ? {connectionError} : null} + // Mobile root: full-screen sidebar-as-list. + if (isCompactLayout && view.kind === "root") { + return ( + + + + + + {addHostModals} + ); + } + + // Mobile detail: full-screen content with a back header that returns to the list. + if (isCompactLayout) { + return ( + + + + {content} + + {addHostModals} + + ); + } + + // Desktop split view — mirrors AppContainer: sidebar owns the titlebar drag + // region + traffic-light padding; detail pane renders whatever header the + // selected section provides. + return ( + + + + + {detailHeader ? ( + + + + + + {detailHeader.title} + + {detailHeader.titleAccessory} + + } + leftStyle={desktopStyles.detailLeft} + /> + ) : null} + + {content} + + + + {addHostModals} ); } @@ -1768,137 +919,19 @@ const styles = StyleSheet.create((theme) => ({ maxWidth: 720, alignSelf: "center", }, - label: { + aboutValue: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - marginBottom: theme.spacing[2], + fontSize: theme.fontSize.sm, }, - input: { - backgroundColor: theme.colors.surface0, - color: theme.colors.foreground, - padding: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - fontSize: theme.fontSize.base, - }, - // Host card styles - hostCard: { - marginBottom: theme.spacing[3], - overflow: "hidden", - }, - hostCardContent: { - padding: theme.spacing[4], - gap: theme.spacing[2], - }, - hostHeaderRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - }, - hostHeaderRight: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flexShrink: 0, - }, - hostLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - flex: 1, - flexShrink: 1, - }, - hostError: { + aboutErrorText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, + marginTop: theme.spacing[1], }, - // Status pill - statusPill: { - flexDirection: "row", - alignItems: "center", - gap: 6, - paddingHorizontal: theme.spacing[2], - paddingVertical: 4, - borderRadius: theme.borderRadius.full, - }, - statusPillMobile: { - alignItems: "center", - justifyContent: "center", - width: 14, - height: 14, - borderRadius: 7, - }, - statusDot: { - width: 6, - height: 6, - borderRadius: theme.borderRadius.full, - }, - statusText: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - }, - connectionPill: { - flexDirection: "row", - alignItems: "center", - gap: 6, - paddingHorizontal: theme.spacing[2], - paddingVertical: 4, - borderRadius: theme.borderRadius.full, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface3, - maxWidth: 170, - }, - connectionPillMobile: { - flexDirection: "row", - alignItems: "center", - borderRadius: theme.borderRadius.full, - }, - connectionText: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - flexShrink: 1, - }, - versionPill: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: theme.spacing[2], - paddingVertical: 4, - borderRadius: theme.borderRadius.full, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface3, - maxWidth: 200, - }, - hostSettingsButton: { - width: 28, - height: 28, - paddingVertical: 0, - paddingHorizontal: 0, - borderRadius: theme.borderRadius.md, - gap: 0, - marginLeft: theme.spacing[2], - alignItems: "center", - justifyContent: "center", - }, - advancedTrigger: { + aboutUpdateActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: "transparent", - }, - advancedTriggerText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, }, themeTrigger: { flexDirection: "row", @@ -1914,154 +947,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, - disabled: { - opacity: theme.opacity[50], - }, - testResultText: { - fontSize: theme.fontSize.xs, - }, - // Add host button - addButton: { - borderStyle: "dashed", - }, - addButtonText: { - color: theme.colors.foregroundMuted, - }, - // Add/Edit form - formCard: { - padding: theme.spacing[4], - marginBottom: theme.spacing[3], - gap: theme.spacing[4], - }, - formTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, - formField: { - gap: theme.spacing[2], - }, - formActionsRow: { - flexDirection: "row", - justifyContent: "flex-end", - gap: theme.spacing[2], - }, - formButton: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[4], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - }, - formButtonPrimary: { - backgroundColor: theme.colors.palette.blue[500], - borderColor: theme.colors.palette.blue[500], - }, - formButtonText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - formButtonPrimaryText: { - color: theme.colors.palette.white, - }, - providerRefreshButton: { - width: 30, - height: 30, - borderRadius: theme.borderRadius.full, - justifyContent: "center", - }, - providerRefreshButtonHovered: { - backgroundColor: theme.colors.surface2, - }, - providerRefreshButtonDisabled: { - opacity: 0.5, - }, - // Audio settings card - audioCard: { - overflow: "hidden", - }, - audioRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - audioRowBorder: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - audioRowContent: { - flex: 1, - marginRight: theme.spacing[3], - }, - audioRowTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - audioRowSubtitle: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, - aboutValue: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, - aboutHintText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - marginTop: 2, - }, - aboutErrorText: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.xs, - marginTop: theme.spacing[1], - }, - aboutUpdateActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - // Empty state - emptyCard: { - padding: theme.spacing[4], - marginBottom: theme.spacing[3], - }, - emptyText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - textAlign: "center", - }, - // Dev section - devCard: { - overflow: "hidden", - }, - devButton: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - devButtonBorder: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - devButtonContent: { - flex: 1, - }, - devButtonTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - devButtonDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: 2, - }, })); const desktopStyles = StyleSheet.create((theme) => ({ @@ -2069,34 +954,59 @@ const desktopStyles = StyleSheet.create((theme) => ({ flex: 1, flexDirection: "row", }, - sidebar: { - width: 200, - borderRightWidth: 1, - borderRightColor: theme.colors.border, - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[2], - gap: theme.spacing[1], - }, - sidebarItem: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - }, - sidebarLabel: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - fontWeight: theme.fontWeight.normal, - }, - sidebarSeparator: { - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - marginVertical: theme.spacing[2], - marginHorizontal: theme.spacing[3], - }, contentPane: { flex: 1, }, + detailLeft: { + gap: theme.spacing[2], + }, +})); + +const sidebarStyles = StyleSheet.create((theme) => ({ + desktopContainer: { + width: 320, + borderRightWidth: 1, + borderRightColor: theme.colors.border, + backgroundColor: theme.colors.surfaceSidebar, + }, + mobileContainer: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + }, + list: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + gap: theme.spacing[1], + }, + item: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 36, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + }, + itemHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + itemSelected: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + label: { + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + fontWeight: theme.fontWeight.normal, + flex: 1, + }, + localMarker: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + paddingHorizontal: theme.spacing[2], + paddingVertical: 2, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, + }, })); diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx new file mode 100644 index 000000000..97a3ce9f0 --- /dev/null +++ b/packages/app/src/screens/settings/host-page.tsx @@ -0,0 +1,783 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Pressable, Text, TextInput, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native"; +import type { HostConnection, HostProfile } from "@/types/host-connection"; +import { + getHostRuntimeStore, + isHostRuntimeConnected, + useHostRuntimeClient, + useHostRuntimeIsConnected, + useHostRuntimeSnapshot, + useHostMutations, + useHosts, +} from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { settingsStyles } from "@/styles/settings"; +import { Button } from "@/components/ui/button"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { ProvidersSection } from "@/screens/settings/providers-section"; +import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; +import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; + +const RESTART_CONFIRMATION_MESSAGE = + "This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically."; + +function formatHostConnectionLabel(connection: HostConnection): string { + if (connection.type === "relay") { + return `Relay (${connection.relayEndpoint})`; + } + if (connection.type === "directSocket" || connection.type === "directPipe") { + return `Local (${connection.path})`; + } + return `TCP (${connection.endpoint})`; +} + +function formatActiveConnectionBadge( + activeConnection: { type: HostConnection["type"]; display: string } | null, + theme: ReturnType["theme"], +): { icon: React.ReactNode; text: string } | null { + if (!activeConnection) return null; + if (activeConnection.type === "relay") { + return { + icon: , + text: "Relay", + }; + } + if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") { + return { + icon: , + text: "Local", + }; + } + return { + icon: , + text: activeConnection.display, + }; +} + +function formatDaemonVersionBadge(version: string | null): string | null { + const trimmed = version?.trim(); + if (!trimmed) return null; + return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; +} + +export interface HostPageProps { + serverId: string; + onHostRemoved?: () => void; +} + +export function HostPage({ serverId, onHostRemoved }: HostPageProps) { + const daemons = useHosts(); + const host = daemons.find((entry) => entry.serverId === serverId) ?? null; + 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); + const statusColor = + statusTone === "success" + ? theme.colors.palette.green[400] + : statusTone === "warning" + ? theme.colors.palette.amber[500] + : statusTone === "error" + ? theme.colors.destructive + : theme.colors.foregroundMuted; + const statusPillBg = + statusTone === "success" + ? "rgba(74, 222, 128, 0.1)" + : statusTone === "warning" + ? "rgba(245, 158, 11, 0.1)" + : statusTone === "error" + ? "rgba(248, 113, 113, 0.1)" + : "rgba(161, 161, 170, 0.1)"; + const connectionBadge = formatActiveConnectionBadge(activeConnection, theme); + const versionBadgeText = formatDaemonVersionBadge(daemonVersion); + const connectionError = + typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; + + if (!host) { + return ( + + + Host not found. + + + ); + } + + return ( + + + + + {statusLabel} + + {connectionBadge ? ( + + {connectionBadge.icon} + + {connectionBadge.text} + + + ) : null} + {versionBadgeText ? ( + + + {versionBadgeText} + + + ) : null} + + {connectionError ? {connectionError} : null} + + + + + + + + + + ); +} + +export function HostRenameButton({ host }: { host: HostProfile }) { + const { theme } = useUnistyles(); + const { renameHost } = useHostMutations(); + const [isEditing, setIsEditing] = useState(false); + const [draftLabel, setDraftLabel] = useState(host.label ?? ""); + const [isSaving, setIsSaving] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + setDraftLabel(host.label ?? ""); + }, [host.serverId, host.label]); + + useEffect(() => { + if (isEditing) { + const timeout = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(timeout); + } + return undefined; + }, [isEditing]); + + const handleSave = useCallback(async () => { + const nextLabel = draftLabel.trim(); + if (!nextLabel) { + Alert.alert("Label required", "Enter a label for this host."); + return; + } + if (isSaving) return; + if (nextLabel === host.label.trim()) { + setIsEditing(false); + return; + } + try { + setIsSaving(true); + await renameHost(host.serverId, nextLabel); + setIsEditing(false); + } catch (error) { + console.error("[HostPage] Failed to rename host", error); + Alert.alert("Error", "Unable to save host"); + } finally { + setIsSaving(false); + } + }, [draftLabel, host.label, host.serverId, isSaving, renameHost]); + + const handleCancel = useCallback(() => { + if (isSaving) return; + setDraftLabel(host.label ?? ""); + setIsEditing(false); + }, [host.label, isSaving]); + + return ( + <> + { + setDraftLabel(host.label ?? ""); + setIsEditing(true); + }} + hitSlop={8} + style={styles.identityEditButton} + accessibilityRole="button" + accessibilityLabel="Edit label" + testID="host-page-label-edit-button" + > + + + + + + void handleSave()} + style={styles.renameInput} + testID="host-page-label-input" + /> + + + + + + + + ); +} + +function ConnectionsSection({ host }: { host: HostProfile }) { + const { removeConnection } = useHostMutations(); + const snapshot = useHostRuntimeSnapshot(host.serverId); + const probeByConnectionId = snapshot?.probeByConnectionId ?? new Map(); + const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ + connectionId: string; + title: string; + } | null>(null); + const [isRemovingConnection, setIsRemovingConnection] = useState(false); + + return ( + + + {host.connections.map((conn, index) => { + const probe = probeByConnectionId.get(conn.id); + return ( + 0} + latencyMs={probe?.status === "available" ? probe.latencyMs : undefined} + latencyLoading={!probe || probe.status === "pending"} + latencyError={probe?.status === "unavailable"} + onRemove={() => { + setPendingRemoveConnection({ + connectionId: conn.id, + title: formatHostConnectionLabel(conn), + }); + }} + /> + ); + })} + + + {pendingRemoveConnection ? ( + { + if (isRemovingConnection) return; + setPendingRemoveConnection(null); + }} + testID="remove-connection-confirm-modal" + > + + Remove {pendingRemoveConnection.title}? This cannot be undone. + + + + + + + ) : null} + + ); +} + +function ConnectionRow({ + connection, + showBorder, + latencyMs, + latencyLoading, + latencyError, + onRemove, +}: { + connection: HostConnection; + showBorder: boolean; + latencyMs: number | null | undefined; + latencyLoading: boolean; + latencyError: boolean; + onRemove: () => void; +}) { + const { theme } = useUnistyles(); + const title = formatHostConnectionLabel(connection); + + const latencyText = (() => { + if (latencyLoading) return "..."; + if (latencyError) return "Timeout"; + if (latencyMs != null) return `${latencyMs}ms`; + return "\u2014"; + })(); + const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted; + + return ( + + + + {title} + + + {latencyText} + + + ); +} + +function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) { + return ( + <> + + + + + {isLocalDaemon ? ( + + + + ) : null} + {isLocalDaemon ? : null} + + ); +} + +const delay = (ms: number) => + new Promise((resolve) => { + const timeout = setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, ms); + }); + +function RestartDaemonCard({ host }: { host: HostProfile }) { + const { theme } = useUnistyles(); + const daemonClient = useHostRuntimeClient(host.serverId); + const isConnected = useHostRuntimeIsConnected(host.serverId); + const runtime = getHostRuntimeStore(); + const [isRestarting, setIsRestarting] = useState(false); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + const isHostConnected = useCallback( + () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)), + [host.serverId, runtime], + ); + + const waitForCondition = useCallback( + async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isMountedRef.current) return false; + if (predicate()) return true; + await delay(intervalMs); + } + return predicate(); + }, + [], + ); + + const waitForDaemonRestart = useCallback(async () => { + const disconnectTimeoutMs = 7000; + const reconnectTimeoutMs = 30000; + if (isHostConnected()) { + await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs); + } + const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs); + if (isMountedRef.current) { + setIsRestarting(false); + if (!reconnected) { + Alert.alert( + "Unable to reconnect", + `${host.label} did not come back online. Please verify it restarted.`, + ); + } + } + }, [host.label, isHostConnected, waitForCondition]); + + const handleRestart = useCallback(() => { + if (!daemonClient) { + Alert.alert( + "Host unavailable", + "This host is not connected. Wait for it to come online before restarting.", + ); + return; + } + if (!isHostConnected()) { + Alert.alert( + "Host offline", + "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting.", + ); + return; + } + + void confirmDialog({ + title: `Restart ${host.label}`, + message: RESTART_CONFIRMATION_MESSAGE, + confirmLabel: "Restart", + cancelLabel: "Cancel", + destructive: true, + }) + .then((confirmed) => { + if (!confirmed) return; + setIsRestarting(true); + void daemonClient + .restartServer(`settings_daemon_restart_${host.serverId}`) + .catch((error) => { + console.error(`[HostPage] Failed to restart daemon ${host.label}`, error); + if (!isMountedRef.current) return; + setIsRestarting(false); + Alert.alert( + "Error", + "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online.", + ); + }); + void waitForDaemonRestart(); + }) + .catch((error) => { + console.error(`[HostPage] Failed to open restart confirmation for ${host.label}`, error); + Alert.alert("Error", "Unable to open the restart confirmation dialog."); + }); + }, [daemonClient, host.label, host.serverId, isHostConnected, waitForDaemonRestart]); + + return ( + + + + Restart daemon + + Restarts the daemon process. The app will reconnect automatically. + + + + + + ); +} + +function InjectPaseoToolsCard({ serverId }: { serverId: string }) { + const isConnected = useHostRuntimeIsConnected(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); + + if (!isConnected) return null; + + return ( + + + + Inject Paseo tools + + Automatically inject Paseo MCP tools into new agents + + + { + void patchConfig({ + mcp: { + injectIntoAgents: value === "on", + }, + }); + }} + options={[ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, + ]} + /> + + + ); +} + +function PairDeviceRow() { + const { theme } = useUnistyles(); + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + + setIsModalOpen(true)} + accessibilityRole="button" + testID="host-page-pair-device-row" + > + + Pair a device + + Scan a QR code or copy a link to connect your phone to this host. + + + + + + setIsModalOpen(false)} + testID="host-page-pair-device-card" + /> + + ); +} + +function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: () => void }) { + const { theme } = useUnistyles(); + const { removeHost } = useHostMutations(); + const [isConfirming, setIsConfirming] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + + return ( + + + + + Remove host + + Removes this host and its saved connections from this device. + + + + + + + {isConfirming ? ( + { + if (isRemoving) return; + setIsConfirming(false); + }} + testID="remove-host-confirm-modal" + > + + Remove {host.label}? This will delete its saved connections. + + + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + identityEditButton: { + padding: theme.spacing[1], + borderRadius: theme.borderRadius.md, + }, + identityBadges: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexWrap: "wrap", + marginBottom: theme.spacing[6], + }, + statusPill: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + borderRadius: theme.borderRadius.full, + }, + statusDot: { + width: 6, + height: 6, + borderRadius: theme.borderRadius.full, + }, + statusText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + badgePill: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: theme.spacing[2], + paddingVertical: 4, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + maxWidth: 200, + }, + badgeText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + errorText: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + marginBottom: theme.spacing[2], + }, + connectionLatency: { + fontSize: theme.fontSize.sm, + marginRight: theme.spacing[2], + }, + confirmText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + confirmActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginTop: theme.spacing[4], + }, + emptyCard: { + padding: theme.spacing[4], + alignItems: "center", + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + renameBody: { + gap: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + renameInput: { + backgroundColor: theme.colors.surface0, + color: theme.colors.foreground, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, + renameActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index dd3ba0d65..7f3349a5d 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -3,6 +3,7 @@ import { View, Text } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { StyleSheet } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/screens/settings/settings-section"; import { Button } from "@/components/ui/button"; import { Shortcut } from "@/components/ui/shortcut"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; @@ -176,30 +177,29 @@ export function KeyboardShortcutsSection() { if (isNative) { return ( - - Shortcuts + Keyboard shortcuts are only available on desktop. - + ); } - return ( - - - Shortcuts - {hasOverrides && ( - - )} - + const resetAllButton = hasOverrides ? ( + + ) : undefined; - {sections.map(function (section) { + return ( + <> + {sections.map(function (section, sectionIndex) { return ( - - {section.title} + {section.rows.map(function (row, index) { const bindingId = getBindingIdForAction(row.id, { @@ -233,22 +233,14 @@ export function KeyboardShortcutsSection() { ); })} - + ); })} - + ); } const styles = StyleSheet.create((theme) => ({ - subsectionTitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - marginBottom: theme.spacing[2], - marginTop: theme.spacing[4], - marginLeft: theme.spacing[1], - }, row: { flexDirection: "row", justifyContent: "space-between", diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx new file mode 100644 index 000000000..e4011de68 --- /dev/null +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { settingsStyles } from "@/styles/settings"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { buildProviderDefinitions } from "@/utils/provider-definitions"; +import { getProviderIcon } from "@/components/provider-icons"; +import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; +import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { SettingsSection } from "@/screens/settings/settings-section"; + +export interface ProvidersSectionProps { + serverId: string; +} + +export function ProvidersSection({ serverId }: ProvidersSectionProps) { + const { theme } = useUnistyles(); + const isConnected = useHostRuntimeIsConnected(serverId); + const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(serverId); + const [diagnosticProvider, setDiagnosticProvider] = useState(null); + const providerDefinitions = buildProviderDefinitions(entries); + const providerRefreshInFlight = + isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false); + const hasServer = serverId.length > 0; + + const refreshAction = + hasServer && isConnected ? ( + void refresh()} + disabled={providerRefreshInFlight} + hitSlop={8} + style={settingsStyles.sectionHeaderLink} + accessibilityRole="button" + accessibilityLabel="Refresh providers" + > + + + ) : undefined; + + return ( + <> + + {!hasServer || !isConnected ? ( + + Connect to this host to see providers + + ) : isLoading ? ( + + Loading... + + ) : ( + + {providerDefinitions.map((def, index) => { + const entry = entries?.find((e) => e.provider === def.id); + const status = entry?.status ?? "unavailable"; + const ProviderIcon = getProviderIcon(def.id); + const providerError = + status === "error" && + typeof entry?.error === "string" && + entry.error.trim().length > 0 + ? entry.error.trim() + : null; + const modelCount = entry?.models?.length ?? 0; + + return ( + 0 && settingsStyles.rowBorder]} + onPress={() => setDiagnosticProvider(def.id)} + accessibilityRole="button" + > + + + + {def.label} + + {providerError ? ( + + {providerError} + + ) : null} + {status === "ready" && modelCount > 0 ? ( + + {modelCount === 1 ? "1 model" : `${modelCount} models`} + + ) : null} + + + + ); + })} + + )} + + + {diagnosticProvider ? ( + setDiagnosticProvider(null)} + serverId={serverId} + /> + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + sectionSpacing: { + marginBottom: theme.spacing[4], + }, + emptyCard: { + padding: theme.spacing[4], + alignItems: "center", + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + errorText: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + marginTop: theme.spacing[1], + }, +})); diff --git a/packages/app/src/screens/settings/settings-section.tsx b/packages/app/src/screens/settings/settings-section.tsx new file mode 100644 index 000000000..b67dfec5f --- /dev/null +++ b/packages/app/src/screens/settings/settings-section.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from "react"; +import { Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { settingsStyles } from "@/styles/settings"; + +interface SettingsSectionProps { + title: string; + trailing?: ReactNode; + testID?: string; + style?: StyleProp; + children: ReactNode; +} + +/** + * iOS-style grouped settings block: muted label + children stacked with a + * consistent gap. The single primitive used for every section across settings; + * don't reach for ad-hoc `` headers or bare card margins. + */ +export function SettingsSection({ + title, + trailing, + testID, + style, + children, +}: SettingsSectionProps) { + return ( + + + {title} + {trailing} + + {children} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + marginBottom: theme.spacing[3], + marginLeft: theme.spacing[1], + }, + content: { + gap: theme.spacing[3], + }, +})); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index ca4b06c42..02f2f5042 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -372,12 +372,35 @@ export function buildHostNewWorkspaceRoute( return `${base}/new?${params.toString()}` as const; } -export function buildHostSettingsRoute(serverId: string) { - const base = buildHostRootRoute(serverId); - if (base === "/") { - return "/" as const; +export const SETTINGS_SECTION_SLUGS = [ + "general", + "shortcuts", + "integrations", + "permissions", + "diagnostics", + "about", +] as const; + +export type SettingsSectionSlug = (typeof SETTINGS_SECTION_SLUGS)[number]; + +export function isSettingsSectionSlug(value: string): value is SettingsSectionSlug { + return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value); +} + +export function buildSettingsRoute() { + return "/settings" as const; +} + +export function buildSettingsSectionRoute(section: SettingsSectionSlug) { + return `/settings/${section}` as const; +} + +export function buildSettingsHostRoute(serverId: string) { + const normalized = trimNonEmpty(serverId); + if (!normalized) { + throw new Error("buildSettingsHostRoute requires a non-empty serverId"); } - return `${base}/settings` as const; + return `/settings/hosts/${encodeSegment(normalized)}` as const; } export function mapPathnameToServer(pathname: string, nextServerId: string) { @@ -389,7 +412,7 @@ export function mapPathnameToServer(pathname: string, nextServerId: string) { const suffix = pathname.replace(/^\/h\/[^/]+\/?/, ""); const base = buildHostRootRoute(normalized); if (suffix.startsWith("settings")) { - return `${base}/settings` as const; + return buildSettingsHostRoute(normalized); } if (suffix.startsWith("sessions")) { return `${base}/sessions` as const; From 4d6a154f6e5e6830c8ecd5c94ba36d90bbd97901 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Apr 2026 15:06:42 +0700 Subject: [PATCH 2/3] Fix Unistyles theme splits --- docs/UNISTYLES.md | 166 ++++++++++++++++++ .../src/components/adaptive-modal-sheet.tsx | 5 +- .../app/src/components/welcome-screen.tsx | 44 +---- 3 files changed, 175 insertions(+), 40 deletions(-) create mode 100644 docs/UNISTYLES.md diff --git a/docs/UNISTYLES.md b/docs/UNISTYLES.md new file mode 100644 index 000000000..bbb00093c --- /dev/null +++ b/docs/UNISTYLES.md @@ -0,0 +1,166 @@ +# Unistyles Gotchas + +This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change. + +That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles. + +## How Updates Propagate + +For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders. + +The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values. + +[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant. + +## Main Gotcha: `contentContainerStyle` + +`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section. + +Avoid this pattern when the style depends on the theme: + +```tsx + + +const styles = StyleSheet.create((theme) => ({ + container: { + flexGrow: 1, + backgroundColor: theme.colors.surface0, + }, +})); +``` + +On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons. + +This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, and library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`. + +## Fix Patterns + +Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free. + +```tsx + + + {children} + + + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + contentContainer: { + flexGrow: 1, + padding: theme.spacing[4], + }, +})); +``` + +This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout. + +When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles): + +```tsx +import { ScrollView } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; + +const ThemedScrollView = withUnistyles(ScrollView); + + +``` + +`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles. + +The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React: + +```tsx +const { theme } = useUnistyles(); + + +``` + +Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value. + +## Hidden Sheet Content + +`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly. + +We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch: + +```tsx +const { theme } = useUnistyles(); + + + {title} + +``` + +Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content. + +## Adaptive Themes And Persisted Settings + +Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage. + +Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load: + +1. Unistyles config starts with `adaptiveThemes: true`. +2. The device may report system light. +3. Settings load a persisted non-auto preference, such as dark. +4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`. + +That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion. + +If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`. + +## Debugging + +To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`: + +```js +[ + "react-native-unistyles/plugin", + { + root: "src", + debug: true, + }, +], +``` + +Then rebuild the bundle and look for lines such as: + +```text +src/components/welcome-screen.tsx: styles.container: [Theme] +``` + +This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about. + +For paint-layer bugs, use high-contrast probes: + +1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta. +2. Cold-restart the app, not just Fast Refresh. +3. Screenshot the simulator and sample pixels to see which color fills the area. +4. Remove the probes before committing. + +The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md). + +## References + +- [Unistyles v3 documentation](https://www.unistyl.es/) +- [Theming: initial theme, adaptive themes, and runtime theme changes](https://www.unistyl.es/v3/guides/theming) +- [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) +- [withUnistyles reference](https://www.unistyl.es/v3/references/with-unistyles) +- [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) +- [Babel plugin debug option](https://www.unistyl.es/v3/other/babel-plugin#debug) +- [Why my view doesn't update?](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) +- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550) +- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817) +- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030) +- [Local research note: welcome theme split]() diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 6b179124c..47e39166c 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -85,7 +85,6 @@ const styles = StyleSheet.create((theme) => ({ }, title: { flex: 1, - color: theme.colors.foreground, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, }, @@ -251,7 +250,7 @@ export function AdaptiveModalSheet({ > - + {title} {subtitle} @@ -280,7 +279,7 @@ export function AdaptiveModalSheet({ <> - + {title} {subtitle} diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index af5db1baf..e431b1341 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; @@ -21,6 +21,8 @@ import { PaseoLogo } from "@/components/icons/paseo-logo"; import { openExternalUrl } from "@/utils/open-external-url"; import { isWeb, isNative } from "@/constants/platform"; +const ThemedScrollView = withUnistyles(ScrollView); + type WelcomeAction = { key: "scan-qr" | "direct-connection" | "paste-pairing-link"; label: string; @@ -237,35 +239,6 @@ export interface WelcomeScreenProps { export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const { theme } = useUnistyles(); - useEffect(() => { - const probe = (tag: string) => { - // eslint-disable-next-line no-console - console.log(`[trace-theme] ${tag}`, { - runtimeName: UnistylesRuntime.themeName, - hookSurface0: theme.colors.surface0, - hookForeground: theme.colors.foreground, - hookSurface2: theme.colors.surface2, - stylesContainerBg: (styles.container as { backgroundColor?: string } | undefined) - ?.backgroundColor, - stylesTitleColor: (styles.title as { color?: string } | undefined)?.color, - stylesActionButtonBg: (styles.actionButton as { backgroundColor?: string } | undefined) - ?.backgroundColor, - stylesActionButtonPrimaryBg: ( - styles.actionButtonPrimary as { backgroundColor?: string } | undefined - )?.backgroundColor, - stylesSubtitleColor: (styles.subtitle as { color?: string } | undefined)?.color, - }); - }; - probe("poll-t0"); - const t1 = setTimeout(() => probe("poll-t1s"), 1000); - const t2 = setTimeout(() => probe("poll-t3s"), 3000); - const t3 = setTimeout(() => probe("poll-t6s"), 6000); - return () => { - clearTimeout(t1); - clearTimeout(t2); - clearTimeout(t3); - }; - }, [theme]); const insets = useSafeAreaInsets(); const router = useRouter(); const appVersion = resolveAppVersion(); @@ -336,14 +309,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const showHostList = hosts.length > 0 && !anyOnlineServerId; return ( - - + ); } From f483e62231afe527c20c7b9f0149c0927063cfa8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Apr 2026 16:08:43 +0700 Subject: [PATCH 3/3] wip(app): keep keyboard shortcuts alive on /settings Chrome gating currently disables shortcuts on non-workspace routes. Temporary pathname check until chromeEnabled is split into separate workspace/global concerns. --- packages/app/src/app/_layout.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index f317da69d..a3cb25a8c 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -403,6 +403,12 @@ function AppContainer({ const isCompactLayout = useIsCompactFormFactor(); const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; + const pathname = usePathname(); + // TODO: stop matching pathname here as a branch. `chromeEnabled` should not + // conflate workspace/project-specific chrome (sidebar, mobile gesture) with + // global concerns like keyboard shortcuts. Split those out so settings (and + // other non-workspace routes) don't need a special-case to keep shortcuts alive. + const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings"); useEffect(() => { const bp = UnistylesRuntime.breakpoint; @@ -434,7 +440,7 @@ function AppContainer({ }, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]); useKeyboardShortcuts({ - enabled: chromeEnabled, + enabled: keyboardShortcutsEnabled, isMobile: isCompactLayout, toggleAgentList, toggleFileExplorer,