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/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 b0624229e..f35616127 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -404,6 +404,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;
@@ -435,7 +441,7 @@ function AppContainer({
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
useKeyboardShortcuts({
- enabled: chromeEnabled,
+ enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout,
toggleAgentList,
toggleFileExplorer,
@@ -805,7 +811,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/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/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..e431b1341 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, 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";
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";
@@ -20,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;
@@ -30,6 +33,10 @@ type WelcomeAction = {
};
const styles = StyleSheet.create((theme) => ({
+ scrollView: {
+ flex: 1,
+ backgroundColor: theme.colors.surface0,
+ },
container: {
flexGrow: 1,
backgroundColor: theme.colors.surface0,
@@ -47,14 +54,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 +127,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 +144,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 {
@@ -243,13 +249,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]);
@@ -309,8 +309,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const showHostList = hosts.length > 0 && !anyOnlineServerId;
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 +369,17 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
))}
)}
+
+
{appVersionText}
@@ -386,6 +400,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
finishOnboarding(serverId);
}}
/>
-
+
);
}
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 = (
+ }
+ onPress={() => {
+ void refreshPermissions();
+ }}
+ disabled={isBusy}
+ accessibilityLabel="Refresh desktop permissions"
+ >
+ {isRefreshing ? "Refreshing..." : "Refresh"}
+
+ );
+
return (
-
-
- Desktop Permissions
- }
- onPress={() => {
- void refreshPermissions();
- }}
- disabled={isBusy}
- accessibilityLabel="Refresh desktop permissions"
- >
- {isRefreshing ? "Refreshing..." : "Refresh"}
-
-
+
-
+
);
}
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 = (
+ }
+ textStyle={settingsStyles.sectionHeaderLinkText}
+ style={settingsStyles.sectionHeaderLink}
+ onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
+ accessibilityLabel="Open advanced daemon settings"
+ >
+ Advanced settings
+
+ );
+
return (
-
-
- Built-in daemon
- }
- textStyle={settingsStyles.sectionHeaderLinkText}
- style={settingsStyles.sectionHeaderLink}
- onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
- accessibilityLabel="Open advanced daemon settings"
- >
- Advanced settings
-
-
+
{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."}
-
-
-
- ) : (
-
- )
- }
- onPress={handleToggleDaemonManagement}
- disabled={isUpdatingDaemonManagement}
- >
- {isUpdatingDaemonManagement
- ? isDaemonManagementPaused
- ? "Resuming..."
- : "Pausing..."
- : isDaemonManagementPaused
- ? "Resume"
- : "Pause"}
-
-
-
-
- {daemonActionLabel}
- {daemonActionMessage}
- {statusMessage ? {statusMessage} : null}
-
- }
- onPress={handleUpdateLocalDaemon}
- disabled={isRestartingDaemon}
- >
- {isRestartingDaemon
- ? daemonStatus?.status === "running"
- ? "Restarting..."
- : "Starting..."
- : daemonActionLabel}
-
-
- >
- ) : 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."}
+
+
+
+ ) : (
+
+ )
+ }
+ onPress={handleToggleDaemonManagement}
+ disabled={isUpdatingDaemonManagement}
+ >
+ {isUpdatingDaemonManagement
+ ? isDaemonManagementPaused
+ ? "Resuming..."
+ : "Pausing..."
+ : isDaemonManagementPaused
+ ? "Resume"
+ : "Pause"}
+
+
+
+
+ {daemonActionLabel}
+ {daemonActionMessage}
+ {statusMessage ? {statusMessage} : null}
+
+ }
+ onPress={handleUpdateLocalDaemon}
+ disabled={isRestartingDaemon}
+ >
+ {isRestartingDaemon
+ ? daemonStatus?.status === "running"
+ ? "Restarting..."
+ : "Starting..."
+ : daemonActionLabel}
+
+
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 = (
+
+ }
+ textStyle={settingsStyles.sectionHeaderLinkText}
+ style={settingsStyles.sectionHeaderLink}
+ onPress={() => void openExternalUrl(CLI_DOCS_URL)}
+ accessibilityLabel="Open CLI documentation"
+ >
+ CLI docs
+
+ }
+ textStyle={settingsStyles.sectionHeaderLinkText}
+ style={settingsStyles.sectionHeaderLink}
+ onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
+ accessibilityLabel="Open skills documentation"
+ >
+ Skills docs
+
+
+ );
+
return (
-
-
- Integrations
-
-
- }
- textStyle={settingsStyles.sectionHeaderLinkText}
- style={settingsStyles.sectionHeaderLink}
- onPress={() => void openExternalUrl(CLI_DOCS_URL)}
- accessibilityLabel="Open CLI documentation"
- >
- CLI docs
-
-
- }
- textStyle={settingsStyles.sectionHeaderLinkText}
- style={settingsStyles.sectionHeaderLink}
- onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
- accessibilityLabel="Open skills documentation"
- >
- Skills docs
-
-
-
+
@@ -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}
-
+
);
}
@@ -685,159 +266,20 @@ interface AboutSectionProps {
function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
return (
-
- About
-
-
-
- Version
+
+
+
+
+ Version
{appVersionText}
{isDesktopApp ? : null}
-
+
);
}
-// ---------------------------------------------------------------------------
-// Section content switcher
-// ---------------------------------------------------------------------------
-
-interface SettingsSectionContentProps {
- sectionId: SettingsSectionId;
- hostsProps: HostsSectionProps;
- generalProps: GeneralSectionProps;
- providersProps: ProvidersSectionProps;
- diagnosticsProps: DiagnosticsSectionProps;
- aboutProps: AboutSectionProps;
- appVersion: string | null;
- isLocalDaemon: boolean;
- isDesktopApp: boolean;
-}
-
-function SettingsSectionContent({
- sectionId,
- hostsProps,
- generalProps,
- providersProps,
- diagnosticsProps,
- aboutProps,
- appVersion,
- isLocalDaemon,
- isDesktopApp,
-}: SettingsSectionContentProps) {
- switch (sectionId) {
- case "hosts":
- return ;
- case "general":
- return ;
- case "shortcuts":
- return ;
- case "providers":
- return ;
- case "diagnostics":
- return ;
- case "about":
- return ;
- case "integrations":
- return isDesktopApp ? : null;
- case "permissions":
- return isDesktopApp ? : null;
- case "pair-device":
- return isDesktopApp ? : null;
- case "daemon":
- return isDesktopApp ? (
-
- ) : null;
- }
-}
-
-// ---------------------------------------------------------------------------
-// Layouts
-// ---------------------------------------------------------------------------
-
-interface SettingsLayoutProps {
- sections: SettingsSectionDef[];
- sectionContentProps: Omit;
-}
-
-function SettingsMobileLayout({ sections, sectionContentProps }: SettingsLayoutProps) {
- const insets = useSafeAreaInsets();
-
- return (
-
-
- {sections.map((section) => (
-
- ))}
-
-
- );
-}
-
-function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayoutProps) {
- const { theme } = useUnistyles();
- const insets = useSafeAreaInsets();
- const [selectedSectionId, setSelectedSectionId] = useState("hosts");
-
- return (
-
-
- {sections.map((section) => {
- const isSelected = section.id === selectedSectionId;
- const IconComponent = section.icon;
- const showSeparator = section.id === "integrations" || section.id === "providers";
- return (
-
- {showSeparator ? : null}
- setSelectedSectionId(section.id)}
- accessibilityRole="button"
- accessibilityState={{ selected: isSelected }}
- >
-
-
- {section.label}
-
-
-
- );
- })}
-
-
-
-
-
-
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// Desktop app update row (unchanged)
-// ---------------------------------------------------------------------------
-
function DesktopAppUpdateRow() {
const {
isDesktopApp,
@@ -895,12 +337,12 @@ function DesktopAppUpdateRow() {
}
return (
-
-
- App updates
- {statusText}
+
+
+ App updates
+ {statusText}
{availableUpdate?.latestVersion ? (
-
+
Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)}
) : null}
@@ -932,142 +374,207 @@ function DesktopAppUpdateRow() {
);
}
+// ---------------------------------------------------------------------------
+// Sidebar
+// ---------------------------------------------------------------------------
+
+function useAnyOnlineHostServerId(serverIds: string[]): string | null {
+ const runtime = getHostRuntimeStore();
+ return useSyncExternalStore(
+ (onStoreChange) => runtime.subscribeAll(onStoreChange),
+ () => {
+ let firstOnlineServerId: string | null = null;
+ let firstOnlineAt: string | null = null;
+ for (const serverId of serverIds) {
+ const snapshot = runtime.getSnapshot(serverId);
+ const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
+ if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
+ continue;
+ }
+ if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
+ firstOnlineAt = lastOnlineAt;
+ firstOnlineServerId = serverId;
+ }
+ }
+ return firstOnlineServerId;
+ },
+ () => null,
+ );
+}
+
+interface SettingsSidebarProps {
+ view: SettingsView;
+ onSelectSection: (section: SettingsSectionSlug) => void;
+ onSelectHost: (serverId: string) => void;
+ onAddHost: () => void;
+ onBackToWorkspace: () => void;
+ layout: "desktop" | "mobile";
+}
+
+function SettingsSidebar({
+ view,
+ onSelectSection,
+ onSelectHost,
+ onAddHost,
+ onBackToWorkspace,
+ layout,
+}: SettingsSidebarProps) {
+ const { theme } = useUnistyles();
+ const hosts = useHosts();
+ const localServerId = useLocalDaemonServerId();
+ const sortedHosts = useMemo(() => {
+ if (!localServerId) {
+ return hosts;
+ }
+ const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
+ if (localIndex <= 0) {
+ return hosts;
+ }
+ const next = hosts.slice();
+ const [local] = next.splice(localIndex, 1);
+ next.unshift(local);
+ return next;
+ }, [hosts, localServerId]);
+ const isDesktopApp = isElectronRuntime();
+ const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
+ const padding = useWindowControlsPadding("sidebar");
+ const isDesktop = layout === "desktop";
+ const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
+ const selectedSectionId = view.kind === "section" ? view.section : null;
+ const selectedServerId = view.kind === "host" ? view.serverId : null;
+
+ return (
+
+ {isDesktop ? (
+ <>
+
+ {padding.top > 0 ? : null}
+ >
+ ) : null}
+ {isDesktop ? (
+
+ ) : null}
+
+ {items.map((item) => {
+ const isSelected = selectedSectionId === item.id;
+ const IconComponent = item.icon;
+ return (
+ onSelectSection(item.id)}
+ style={({ hovered = false }) => [
+ sidebarStyles.item,
+ hovered && sidebarStyles.itemHovered,
+ isSelected && sidebarStyles.itemSelected,
+ ]}
+ >
+
+
+ {item.label}
+
+
+ );
+ })}
+
+
+
+ {sortedHosts.map((host) => {
+ const isSelected = selectedServerId === host.serverId;
+ const isLocal = localServerId !== null && host.serverId === localServerId;
+ return (
+ onSelectHost(host.serverId)}
+ testID={`settings-host-entry-${host.serverId}`}
+ style={({ hovered = false }) => [
+ sidebarStyles.item,
+ hovered && sidebarStyles.itemHovered,
+ isSelected && sidebarStyles.itemSelected,
+ ]}
+ >
+
+
+ {host.label}
+
+ {isLocal ? (
+
+ Local
+
+ ) : null}
+
+ );
+ })}
+ [
+ sidebarStyles.item,
+ hovered && sidebarStyles.itemHovered,
+ ]}
+ >
+
+
+ Add host
+
+
+
+
+ );
+}
+
// ---------------------------------------------------------------------------
// Main screen
// ---------------------------------------------------------------------------
-export default function SettingsScreen() {
+export interface SettingsScreenProps {
+ view: SettingsView;
+}
+
+export default function SettingsScreen({ view }: SettingsScreenProps) {
+ const router = useRouter();
const { theme } = useUnistyles();
const voiceAudioEngine = useVoiceAudioEngineOptional();
- const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>();
- const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
- const { daemons, renameHost, removeHost, removeConnection } = {
- daemons: useHosts(),
- ...useHostMutations(),
- };
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
- const [pendingRemoveHost, setPendingRemoveHost] = useState(null);
- const [isRemovingHost, setIsRemovingHost] = useState(false);
- const [editingDaemon, setEditingDaemon] = useState(null);
- const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState(null);
- const isLoading = settingsLoading;
- const isMountedRef = useRef(true);
- const lastHandledEditHostRef = useRef(null);
const isDesktopApp = isElectronRuntime();
- const isLocalDaemon = useIsLocalDaemon(routeServerId);
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
- const editingServerId = editingDaemon?.serverId ?? null;
- const editingDaemonLive = editingServerId
- ? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
- : null;
-
- useEffect(() => {
- return () => {
- isMountedRef.current = false;
- };
- }, []);
-
- // Keep the edit modal bound to live registry state.
- useEffect(() => {
- if (!editingServerId) return;
- if (editingDaemonLive) return;
- setEditingDaemon(null);
- }, [editingDaemonLive, editingServerId]);
-
- 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 handleEditDaemon = useCallback((profile: HostProfile) => {
- setEditingDaemon(profile);
- }, []);
-
- const handleCloseEditDaemon = useCallback(() => {
- if (isSavingEdit) return;
- setEditingDaemon(null);
- }, [isSavingEdit]);
-
- const closeAddConnectionFlow = useCallback(() => {
- setIsAddHostMethodVisible(false);
- setIsDirectHostVisible(false);
- setIsPasteLinkVisible(false);
- }, []);
-
- const goBackToAddConnectionMethods = useCallback(() => {
- setIsDirectHostVisible(false);
- setIsPasteLinkVisible(false);
- setIsAddHostMethodVisible(true);
- }, []);
-
- useEffect(() => {
- const editHost = typeof params.editHost === "string" ? params.editHost.trim() : "";
- if (!editHost) return;
- if (lastHandledEditHostRef.current === editHost) return;
- const profile = daemons.find((daemon) => daemon.serverId === editHost) ?? null;
- if (!profile) return;
- lastHandledEditHostRef.current = editHost;
- handleEditDaemon(profile);
- }, [daemons, handleEditDaemon, params.editHost]);
-
- const handleSaveEditDaemon = useCallback(
- async (nextLabelRaw: string) => {
- if (!editingServerId) return;
- if (isSavingEdit) return;
-
- const nextLabel = nextLabelRaw.trim();
- if (!nextLabel) {
- Alert.alert("Label required", "Enter a label for this host.");
- return;
- }
-
- try {
- setIsSavingEdit(true);
- await renameHost(editingServerId, nextLabel);
- handleCloseEditDaemon();
- } catch (error) {
- console.error("[Settings] Failed to rename host", error);
- Alert.alert("Error", "Unable to save host");
- } finally {
- setIsSavingEdit(false);
- }
- },
- [editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost],
- );
-
- const handleRemoveConnection = useCallback(
- async (serverId: string, connectionId: string) => {
- await removeConnection(serverId, connectionId);
- },
- [removeConnection],
- );
-
- const handleRemoveDaemon = useCallback((profile: HostProfile) => {
- setEditingDaemon(null);
- setPendingRemoveHost(profile);
- }, []);
+ const isCompactLayout = useIsCompactFormFactor();
+ const insets = useSafeAreaInsets();
+ const hosts = useHosts();
+ const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
+ const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
const handleThemeChange = useCallback(
- (newTheme: AppSettings["theme"]) => {
- void updateSettings({ theme: newTheme });
+ (nextTheme: AppSettings["theme"]) => {
+ void updateSettings({ theme: nextTheme });
},
[updateSettings],
);
@@ -1108,75 +615,154 @@ export default function SettingsScreen() {
}
}, [isPlaybackTestRunning, voiceAudioEngine]);
- const isCompactLayout = useIsCompactFormFactor();
- const sections = getSettingsSections({ isDesktopApp });
+ const closeAddConnectionFlow = useCallback(() => {
+ setIsAddHostMethodVisible(false);
+ setIsDirectHostVisible(false);
+ setIsPasteLinkVisible(false);
+ }, []);
- const hostsProps: HostsSectionProps = {
- daemons,
- settings,
- routeServerId,
- theme,
- handleEditDaemon,
- setIsAddHostMethodVisible,
- isAddHostMethodVisible,
- isDirectHostVisible,
- isPasteLinkVisible,
- closeAddConnectionFlow,
- goBackToAddConnectionMethods,
- setIsDirectHostVisible,
- setIsPasteLinkVisible,
- pendingRemoveHost,
- setPendingRemoveHost,
- isRemovingHost,
- setIsRemovingHost,
- removeHost,
- editingDaemonLive,
- isSavingEdit,
- handleCloseEditDaemon,
- handleSaveEditDaemon,
- handleRemoveConnection,
- handleRemoveDaemon,
- restartConfirmationMessage:
- "This will restart the daemon. The app will reconnect automatically.",
- waitForCondition,
- isMountedRef,
- };
+ const goBackToAddConnectionMethods = useCallback(() => {
+ setIsDirectHostVisible(false);
+ setIsPasteLinkVisible(false);
+ setIsAddHostMethodVisible(true);
+ }, []);
- const generalProps: GeneralSectionProps = {
- routeServerId,
- settings,
- handleThemeChange,
- handleSendBehaviorChange,
- };
+ const handleAddHost = useCallback(() => {
+ setIsAddHostMethodVisible(true);
+ }, []);
- const providersProps: ProvidersSectionProps = {
- routeServerId,
- };
+ const handleHostAdded = useCallback(
+ ({ serverId }: { serverId: string }) => {
+ const target = buildSettingsHostRoute(serverId);
+ if (isCompactLayout) {
+ router.push(target);
+ } else {
+ router.replace(target);
+ }
+ },
+ [isCompactLayout, router],
+ );
- const diagnosticsProps: DiagnosticsSectionProps = {
- voiceAudioEngine,
- isPlaybackTestRunning,
- playbackTestResult,
- handlePlaybackTest,
- };
+ const handleSelectSection = useCallback(
+ (section: SettingsSectionSlug) => {
+ const target = buildSettingsSectionRoute(section);
+ if (isCompactLayout) {
+ router.push(target);
+ } else {
+ router.replace(target);
+ }
+ },
+ [isCompactLayout, router],
+ );
- const aboutProps: AboutSectionProps = {
- appVersionText,
- isDesktopApp,
- };
+ const handleSelectHost = useCallback(
+ (serverId: string) => {
+ const target = buildSettingsHostRoute(serverId);
+ if (isCompactLayout) {
+ router.push(target);
+ } else {
+ router.replace(target);
+ }
+ },
+ [isCompactLayout, router],
+ );
- const sectionContentProps: Omit = {
- hostsProps,
- generalProps,
- providersProps,
- diagnosticsProps,
- aboutProps,
- appVersion,
- isLocalDaemon,
- isDesktopApp,
- };
+ const handleScanQr = useCallback(() => {
+ closeAddConnectionFlow();
+ router.push({
+ pathname: "/pair-scan",
+ params: { source: "settings" },
+ });
+ }, [closeAddConnectionFlow, router]);
- if (isLoading) {
+ const handleHostRemoved = useCallback(() => {
+ const fallback = buildSettingsSectionRoute("general");
+ if (isCompactLayout) {
+ router.replace("/settings");
+ } else {
+ router.replace(fallback);
+ }
+ }, [isCompactLayout, router]);
+
+ const handleBackToRoot = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace("/settings");
+ }
+ }, [router]);
+
+ const handleBackToWorkspace = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back();
+ return;
+ }
+ if (anyOnlineServerId) {
+ router.replace(buildHostOpenProjectRoute(anyOnlineServerId));
+ return;
+ }
+ router.replace("/");
+ }, [anyOnlineServerId, router]);
+
+ const detailHeader = ((): {
+ title: string;
+ Icon: ComponentType<{ size: number; color: string }>;
+ titleAccessory?: ReactNode;
+ } | null => {
+ if (view.kind === "host") {
+ const host = hosts.find((h) => h.serverId === view.serverId);
+ if (!host) return null;
+ return {
+ title: host.label,
+ Icon: Server,
+ titleAccessory: ,
+ };
+ }
+ if (view.kind === "section") {
+ const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
+ if (!item) return null;
+ return { title: item.label, Icon: item.icon };
+ }
+ return null;
+ })();
+
+ const content = (() => {
+ if (view.kind === "host") {
+ return ;
+ }
+ if (view.kind === "section") {
+ switch (view.section) {
+ case "general":
+ return (
+
+ );
+ case "shortcuts":
+ return isDesktopApp ? : null;
+ case "integrations":
+ return isDesktopApp ? : null;
+ case "permissions":
+ return isDesktopApp ? : null;
+ case "diagnostics":
+ return (
+
+ );
+ case "about":
+ return ;
+ }
+ }
+ return null;
+ })();
+
+ if (settingsLoading) {
return (
Loading settings...
@@ -1184,557 +770,122 @@ export default function SettingsScreen() {
);
}
- return (
-
-
- {isCompactLayout ? (
-
- ) : (
-
- )}
-
- );
-}
-
-// ---------------------------------------------------------------------------
-// HostDetailModal (unchanged)
-// ---------------------------------------------------------------------------
-
-interface HostDetailModalProps {
- visible: boolean;
- host: HostProfile | null;
- isSaving: boolean;
- onClose: () => void;
- onSave: (label: string) => void;
- onRemoveConnection: (serverId: string, connectionId: string) => Promise;
- onRemoveHost: (host: HostProfile) => void;
- restartConfirmationMessage: string;
- waitForCondition: (
- predicate: () => boolean,
- timeoutMs: number,
- intervalMs?: number,
- ) => Promise;
- isScreenMountedRef: MutableRefObject;
-}
-
-function HostDetailModal({
- visible,
- host,
- isSaving,
- onClose,
- onSave,
- onRemoveConnection,
- onRemoveHost,
- restartConfirmationMessage,
- waitForCondition,
- isScreenMountedRef,
-}: HostDetailModalProps) {
- const { theme } = useUnistyles();
- const [draftLabel, setDraftLabel] = useState("");
- const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{
- serverId: string;
- connectionId: string;
- title: string;
- } | null>(null);
- const [isRemovingConnection, setIsRemovingConnection] = useState(false);
-
- // Read per-connection probes from host runtime snapshots.
- const connections = host?.connections ?? [];
-
- // Restart logic (moved from DaemonCard)
- const runtimeSnapshot = useHostRuntimeSnapshot(host?.serverId ?? "");
- const runtimeClient = useHostRuntimeClient(host?.serverId ?? "");
- const isConnected = useHostRuntimeIsConnected(host?.serverId ?? "");
- const runtime = getHostRuntimeStore();
- const daemonClient = runtimeClient;
- const daemonVersion = useSessionStore((state) =>
- host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null,
- );
- const probeByConnectionId = runtimeSnapshot?.probeByConnectionId ?? new Map();
- const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
- const activeConnection = runtimeSnapshot?.activeConnection ?? null;
- const lastError = runtimeSnapshot?.lastError ?? null;
- const [isRestarting, setIsRestarting] = useState(false);
- const isHostConnected = useCallback(() => {
- if (!host) {
- return false;
- }
- return isHostRuntimeConnected(runtime.getSnapshot(host.serverId));
- }, [host, runtime]);
-
- const waitForDaemonRestart = useCallback(async () => {
- const disconnectTimeoutMs = 7000;
- const reconnectTimeoutMs = 30000;
-
- if (isHostConnected()) {
- await waitForCondition(() => !isHostConnected(), disconnectTimeoutMs);
- }
-
- const reconnected = await waitForCondition(() => isHostConnected(), reconnectTimeoutMs);
-
- if (isScreenMountedRef.current) {
- setIsRestarting(false);
- if (!reconnected && host) {
- Alert.alert(
- "Unable to reconnect",
- `${host.label} did not come back online. Please verify it restarted.`,
- );
- }
- }
- }, [host, isHostConnected, isScreenMountedRef, waitForCondition]);
-
- const beginServerRestart = useCallback(() => {
- if (!daemonClient || !host) return;
-
- if (!isHostConnected()) {
- Alert.alert(
- "Host offline",
- "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting.",
- );
- return;
- }
-
- setIsRestarting(true);
- void daemonClient.restartServer(`settings_daemon_restart_${host.serverId}`).catch((error) => {
- console.error(`[Settings] Failed to restart daemon ${host.label}`, error);
- if (!isScreenMountedRef.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();
- }, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]);
-
- const handleRestartPress = useCallback(() => {
- if (!daemonClient || !host) {
- Alert.alert(
- "Host unavailable",
- "This host is not connected. Wait for it to come online before restarting.",
- );
- return;
- }
-
- void confirmDialog({
- title: `Restart ${host.label}`,
- message: restartConfirmationMessage,
- confirmLabel: "Restart",
- cancelLabel: "Cancel",
- destructive: true,
- })
- .then((confirmed) => {
- if (!confirmed) {
- return;
- }
- beginServerRestart();
- })
- .catch((error) => {
- console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error);
- Alert.alert("Error", "Unable to open the restart confirmation dialog.");
- });
- }, [beginServerRestart, daemonClient, host, restartConfirmationMessage]);
-
- // Status display
- 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 = (() => {
- return formatActiveConnectionBadge({
- activeConnection,
- theme,
- });
- })();
- const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
- const connectionError =
- typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
-
- const handleDraftLabelChange = useCallback((nextValue: string) => {
- setDraftLabel(nextValue);
- }, []);
-
- useEffect(() => {
- if (!visible || !host) return;
- // Initialize once per modal open / host switch; keep user edits fully local while typing.
- setDraftLabel(host.label ?? "");
- }, [visible, host?.serverId]);
-
- useEffect(() => {
- if (!visible) {
- setIsRestarting(false);
- setDraftLabel("");
- }
- }, [visible]);
-
- return (
+ const addHostModals = (
<>
-
- {/* Status row */}
-
-
-
- {statusLabel}
-
- {connectionBadge ? (
-
- {connectionBadge.icon}
-
- {connectionBadge.text}
-
-
- ) : null}
- {versionBadgeText ? (
-
-
- {versionBadgeText}
-
-
- ) : null}
-
- {connectionError ? (
-
- {connectionError}
-
- ) : null}
-
- {/* Label */}
-
- Label
-
-
-
- {/* Connections */}
- {host ? (
-
- Connections
-
- {host.connections.map((conn) => {
- const probe = probeByConnectionId.get(conn.id);
- return (
- {
- const title = formatHostConnectionLabel(conn);
- setPendingRemoveConnection({
- serverId: host.serverId,
- connectionId: conn.id,
- title,
- });
- }}
- />
- );
- })}
-
-
- ) : null}
-
- {/* Save/Cancel + Advanced */}
-
-
-
- [styles.advancedTrigger, pressed && { opacity: 0.85 }]}
- >
-
- Advanced
-
-
-
- }
- status={isRestarting ? "pending" : "idle"}
- pendingLabel="Restarting..."
- disabled={!daemonClient || !isConnected}
- >
- Restart daemon
-
- {
- if (host) onRemoveHost(host);
- }}
- leading={}
- >
- Remove host
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 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.
+
+
+ }
+ onPress={handleRestart}
+ disabled={isRestarting || !daemonClient || !isConnected}
+ testID="host-page-restart-button"
+ >
+ {isRestarting ? "Restarting..." : "Restart"}
+
+
+
+ );
+}
+
+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.
+
+
+ }
+ textStyle={{ color: theme.colors.destructive }}
+ onPress={() => setIsConfirming(true)}
+ testID="host-page-remove-host-button"
+ >
+ Remove
+
+
+
+
+ {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;