refactor(app): restructure settings with modular section navigation

This commit is contained in:
Mohamed Boudra
2026-04-18 14:21:40 +07:00
parent 63a46acaf1
commit 5c3cc99d8c
41 changed files with 2526 additions and 2317 deletions
+1 -6
View File
@@ -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) => {
+181
View File
@@ -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();
});
});
@@ -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();
});
});
+3 -1
View File
@@ -802,7 +802,8 @@ function RootStack() {
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen
@@ -819,6 +820,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="index" />
<Stack.Screen name="settings/hosts/[serverId]" />
</Stack>
);
}
+4 -93
View File
@@ -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 (
<HostRouteBootstrapBoundary>
<HostIndexRouteContent />
</HostRouteBootstrapBoundary>
);
}
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 <Redirect href={buildHostOpenProjectRoute(serverId)} />;
}
@@ -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 (
<HostRouteBootstrapBoundary>
<SettingsScreen />
<Redirect href={href} />
</HostRouteBootstrapBoundary>
);
}
+11 -77
View File
@@ -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<string | null>(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 (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={() => router.back()}>
<Text style={styles.headerButtonText}>Close</Text>
</Pressable>
</View>
<BackHeader title="Scan QR" onBack={() => router.back()} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Not available on web</Text>
@@ -282,12 +221,7 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={closeToSource}>
<Text style={styles.headerButtonText}>Close</Text>
</Pressable>
</View>
<BackHeader title="Scan QR" onBack={closeToSource} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
{!granted ? (
-27
View File
@@ -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 <DraftAgentScreen />;
}
return null;
}
@@ -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 <SettingsScreen view={{ kind: "section", section }} />;
}
@@ -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 (
<HostRouteBootstrapBoundary>
<SettingsScreen view={{ kind: "host", serverId }} />
</HostRouteBootstrapBoundary>
);
}
+14
View File
@@ -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 <Redirect href={buildSettingsSectionRoute("general")} />;
}
return <SettingsScreen view={{ kind: "root" }} />;
}
@@ -70,6 +70,7 @@ export function AddHostMethodModal({
<Pressable
style={styles.option}
onPress={handleDirect}
accessibilityRole="button"
accessibilityLabel="Direct connection"
testID="add-host-method-direct"
>
@@ -81,7 +82,12 @@ export function AddHostMethodModal({
</Pressable>
{isNative ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<Pressable
style={styles.option}
onPress={handleScan}
accessibilityRole="button"
accessibilityLabel="Scan QR code"
>
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Scan QR code</Text>
@@ -93,6 +99,7 @@ export function AddHostMethodModal({
<Pressable
style={styles.option}
onPress={handlePaste}
accessibilityRole="button"
accessibilityLabel="Paste pairing link"
testID="add-host-method-pair-link"
>
+2 -18
View File
@@ -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 (
<AdaptiveModalSheet
@@ -1,5 +1,5 @@
import { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Pressable, View } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,6 +8,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { ScreenTitle } from "@/components/headers/screen-title";
interface BranchSwitcherProps {
currentBranchName: string | null;
@@ -46,9 +47,7 @@ export function BranchSwitcher({
const titleContent = (
<>
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
</>
);
@@ -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",
@@ -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 (
<ScreenHeader
left={
<>
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
<Pressable
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
{title && <ScreenTitle>{title}</ScreenTitle>}
{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,
},
}));
@@ -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 <View style={headerIconSlotStyle.slot}>{children}</View>;
}
@@ -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",
@@ -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={
<>
<SidebarMenuToggle />
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
{title && <ScreenTitle>{title}</ScreenTitle>}
</>
}
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,
@@ -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<TextStyle>;
}
/**
* 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 (
<Text style={[styles.text, style]} numberOfLines={numberOfLines} testID={testID}>
{children}
</Text>
);
}
const styles = StyleSheet.create((theme) => ({
text: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
},
}));
+25 -104
View File
@@ -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 (
<Pressable
style={({ hovered }) => [
styles.newAgentButton,
hovered && styles.newAgentButtonHovered,
isActive && styles.newAgentButtonActive,
]}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={onPress}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.newAgentButtonText,
(hovered || isActive) && styles.newAgentButtonTextHovered,
]}
>
Sessions
</Text>
</>
)}
</Pressable>
);
}
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"
>
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -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({
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
{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",
@@ -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 (
<AdaptiveModalSheet
@@ -0,0 +1,90 @@
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { LucideIcon } from "lucide-react-native";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
interface SidebarHeaderRowProps {
icon: LucideIcon;
label: string;
onPress: () => 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 (
<View style={styles.container}>
<Pressable
onPress={onPress}
testID={testID}
nativeID={nativeID}
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
style={({ hovered }) => [styles.button, (hovered || isActive) && styles.buttonHovered]}
>
{({ hovered }) => {
const isHighlighted = hovered || isActive;
const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Icon size={theme.iconSize.md} color={iconColor} />
<Text style={[styles.label, isHighlighted && styles.labelHighlighted]}>{label}</Text>
</>
);
}}
</Pressable>
</View>
);
}
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,
},
}));
@@ -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 <View style={styles.line} />;
}
const styles = StyleSheet.create((theme) => ({
line: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
}));
+1 -1
View File
@@ -181,7 +181,7 @@ export function Button({
]}
>
{renderIcon()}
<Text style={resolvedTextStyle}>{children}</Text>
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
</Pressable>
);
}
+82 -38
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -13,6 +13,7 @@ import {
} from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
@@ -30,6 +31,10 @@ type WelcomeAction = {
};
const styles = StyleSheet.create((theme) => ({
scrollView: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
container: {
flexGrow: 1,
backgroundColor: theme.colors.surface0,
@@ -47,14 +52,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.medium,
marginBottom: theme.spacing[3],
textAlign: "center",
},
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginBottom: theme.spacing[8],
},
copyBlock: {
alignItems: "center",
gap: theme.spacing[2],
marginBottom: theme.spacing[12],
},
actions: {
width: "100%",
@@ -117,19 +125,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
setupHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginBottom: theme.spacing[6],
lineHeight: theme.fontSize.sm * 1.5,
},
setupLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
marginBottom: theme.spacing[6],
},
setupLinkText: {
color: theme.colors.accent,
@@ -142,6 +142,10 @@ const styles = StyleSheet.create((theme) => ({
textAlign: "center",
marginTop: theme.spacing[6],
},
settingsButton: {
alignSelf: "center",
marginTop: theme.spacing[6],
},
}));
function useAnyHostOnline(serverIds: string[]): string | null {
@@ -233,6 +237,35 @@ export interface WelcomeScreenProps {
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
useEffect(() => {
const probe = (tag: string) => {
// eslint-disable-next-line no-console
console.log(`[trace-theme] ${tag}`, {
runtimeName: UnistylesRuntime.themeName,
hookSurface0: theme.colors.surface0,
hookForeground: theme.colors.foreground,
hookSurface2: theme.colors.surface2,
stylesContainerBg: (styles.container as { backgroundColor?: string } | undefined)
?.backgroundColor,
stylesTitleColor: (styles.title as { color?: string } | undefined)?.color,
stylesActionButtonBg: (styles.actionButton as { backgroundColor?: string } | undefined)
?.backgroundColor,
stylesActionButtonPrimaryBg: (
styles.actionButtonPrimary as { backgroundColor?: string } | undefined
)?.backgroundColor,
stylesSubtitleColor: (styles.subtitle as { color?: string } | undefined)?.color,
});
};
probe("poll-t0");
const t1 = setTimeout(() => probe("poll-t1s"), 1000);
const t2 = setTimeout(() => probe("poll-t3s"), 3000);
const t3 = setTimeout(() => probe("poll-t6s"), 6000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}, [theme]);
const insets = useSafeAreaInsets();
const router = useRouter();
const appVersion = resolveAppVersion();
@@ -243,13 +276,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
useEffect(() => {
const currentPathname = typeof window === "undefined" ? null : window.location.pathname || null;
if (currentPathname && currentPathname !== "/welcome") {
return;
}
if (!anyOnlineServerId) {
return;
}
if (!anyOnlineServerId) return;
router.replace(buildHostRootRoute(anyOnlineServerId));
}, [anyOnlineServerId, router]);
@@ -310,32 +337,38 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
return (
<ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
style={[styles.scrollView, { backgroundColor: "#00ffff" }]}
contentContainerStyle={[
styles.container,
{ paddingBottom: theme.spacing[6] + insets.bottom },
{
backgroundColor: "#ff00ff",
paddingBottom: theme.spacing[6] + insets.bottom,
},
]}
showsVerticalScrollIndicator={false}
testID="welcome-screen"
>
<View style={styles.content}>
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
{!showHostList && isNative && (
<>
<Text style={styles.setupHint}>
You need the Paseo desktop app or server running on your computer first.
</Text>
<Pressable style={styles.setupLink} onPress={() => openExternalUrl("https://paseo.sh")}>
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
</>
)}
<View style={styles.copyBlock}>
<Text style={styles.title}>Welcome to Paseo</Text>
{showHostList ? (
<Text style={styles.subtitle}>Connecting to your hosts…</Text>
) : (
<>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
{isNative ? (
<Pressable
style={styles.setupLink}
onPress={() => openExternalUrl("https://paseo.sh")}
>
<Text style={styles.setupLinkText}>paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
) : null}
</>
)}
</View>
<View style={styles.actions}>
{actions.map((action) => {
@@ -366,6 +399,17 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
))}
</View>
)}
<Button
variant="ghost"
size="sm"
leftIcon={Settings}
onPress={() => router.push("/settings")}
style={styles.settingsButton}
testID="welcome-open-settings"
>
Settings
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
@@ -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 (
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>{title}</Text>
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{title}</Text>
</View>
<View style={styles.permissionRowActions}>
{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: {
@@ -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 = (
<Button
variant="ghost"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
onPress={() => {
void refreshPermissions();
}}
disabled={isBusy}
accessibilityLabel="Refresh desktop permissions"
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
);
return (
<View style={settingsStyles.section}>
<View style={styles.permissionSectionHeader}>
<Text style={settingsStyles.sectionTitle}>Desktop Permissions</Text>
<Button
variant="ghost"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
onPress={() => {
void refreshPermissions();
}}
disabled={isBusy}
accessibilityLabel="Refresh desktop permissions"
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
</View>
<SettingsSection title="Permissions" trailing={refreshButton}>
<View style={settingsStyles.card}>
<DesktopPermissionRow
title="Notifications"
@@ -74,18 +75,11 @@ export function DesktopPermissionsSection() {
}}
/>
</View>
</View>
</SettingsSection>
);
}
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],
@@ -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 = (
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
accessibilityLabel="Open advanced daemon settings"
>
Advanced settings
</Button>
);
return (
<View style={settingsStyles.section}>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
accessibilityLabel="Open advanced daemon settings"
>
Advanced settings
</Button>
</View>
<SettingsSection
title="Daemon"
trailing={advancedSettingsButton}
testID="host-page-daemon-lifecycle-card"
>
{isLoading ? (
<View style={[settingsStyles.card, styles.loadingCard]}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
@@ -271,61 +273,57 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
</View>
{showLifecycleControls ? (
<>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{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."}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
</>
) : null}
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{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."}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
@@ -423,7 +421,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
</View>
</AdaptiveModalSheet>
</View>
</SettingsSection>
);
}
@@ -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",
@@ -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 = (
<View style={styles.headerLinks}>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
accessibilityLabel="Open CLI documentation"
>
CLI docs
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
accessibilityLabel="Open skills documentation"
>
Skills docs
</Button>
</View>
);
return (
<View style={settingsStyles.section}>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Integrations</Text>
<View style={styles.headerLinks}>
<Button
variant="ghost"
size="sm"
leftIcon={
<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
accessibilityLabel="Open CLI documentation"
>
CLI docs
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={
<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
}
textStyle={settingsStyles.sectionHeaderLinkText}
style={settingsStyles.sectionHeaderLink}
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
accessibilityLabel="Open skills documentation"
>
Skills docs
</Button>
</View>
</View>
<SettingsSection title="Integrations" trailing={trailing}>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
@@ -166,7 +163,7 @@ export function IntegrationsSection() {
)}
</View>
</View>
</View>
</SettingsSection>
);
}
@@ -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 (
<AdaptiveModalSheet
title="Pair a device"
visible={visible}
onClose={onClose}
snapPoints={["82%", "94%"]}
desktopMaxWidth={640}
testID={testID}
>
<PairDeviceSection />
</AdaptiveModalSheet>
);
}
@@ -45,8 +45,7 @@ export function PairDeviceSection() {
if (!showSection) return null;
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Pair device</Text>
<View style={settingsStyles.section} testID="host-page-pair-device-card">
<View style={settingsStyles.card}>
{pairingQuery.isPending ? (
<View style={styles.centered}>
+3 -4
View File
@@ -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<Href>(() => {
const serverIdFromPath = activeServerId;
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
}, [activeServerId]);
return buildSettingsRoute();
}, []);
const actionItems = useMemo(() => {
if (!open) {
+14 -4
View File
@@ -15,8 +15,7 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
};
}
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;
}
@@ -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) {
@@ -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
</Button>
{isLocalDaemon ? (
<Button
variant="outline"
leftIcon={Smartphone}
onPress={() => setIsPairDeviceOpen(true)}
testID="open-project-pair-device"
>
Pair device
</Button>
) : null}
</View>
</View>
<PairDeviceModal
visible={isPairDeviceOpen}
onClose={() => setIsPairDeviceOpen(false)}
testID="open-project-pair-device-modal"
/>
</View>
);
}
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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<typeof useUnistyles>["theme"],
): { icon: React.ReactNode; text: string } | null {
if (!activeConnection) return null;
if (activeConnection.type === "relay") {
return {
icon: <Globe size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: "Relay",
};
}
if (activeConnection.type === "directSocket" || activeConnection.type === "directPipe") {
return {
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
text: "Local",
};
}
return {
icon: <Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
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 (
<View testID={`settings-host-page-${serverId}`}>
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Host not found.</Text>
</View>
</View>
);
}
return (
<View testID={`settings-host-page-${serverId}`}>
<View style={styles.identityBadges} testID="host-page-identity">
<View style={[styles.statusPill, { backgroundColor: statusPillBg }]}>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
<Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
</View>
{connectionBadge ? (
<View style={styles.badgePill}>
{connectionBadge.icon}
<Text style={styles.badgeText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.badgePill}>
<Text style={styles.badgeText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
{connectionError ? <Text style={styles.errorText}>{connectionError}</Text> : null}
<ConnectionsSection host={host} />
<DaemonSection host={host} isLocalDaemon={isLocalDaemon} />
<ProvidersSection serverId={serverId} />
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
</View>
);
}
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<TextInput>(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 (
<>
<Pressable
onPress={() => {
setDraftLabel(host.label ?? "");
setIsEditing(true);
}}
hitSlop={8}
style={styles.identityEditButton}
accessibilityRole="button"
accessibilityLabel="Edit label"
testID="host-page-label-edit-button"
>
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<AdaptiveModalSheet
visible={isEditing}
onClose={handleCancel}
title="Rename host"
testID="host-page-rename-modal"
>
<View style={styles.renameBody}>
<TextInput
ref={inputRef}
value={draftLabel}
onChangeText={setDraftLabel}
placeholder="My Host"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
onSubmitEditing={() => void handleSave()}
style={styles.renameInput}
testID="host-page-label-input"
/>
<View style={styles.renameActions}>
<Button
variant="secondary"
size="sm"
style={{ flex: 1 }}
onPress={handleCancel}
disabled={isSaving}
>
Cancel
</Button>
<Button
size="sm"
style={{ flex: 1 }}
onPress={() => void handleSave()}
disabled={isSaving}
testID="host-page-label-save"
>
{isSaving ? "Saving..." : "Save"}
</Button>
</View>
</View>
</AdaptiveModalSheet>
</>
);
}
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 (
<SettingsSection title="Connections">
<View style={settingsStyles.card} testID="host-page-connections-card">
{host.connections.map((conn, index) => {
const probe = probeByConnectionId.get(conn.id);
return (
<ConnectionRow
key={conn.id}
connection={conn}
showBorder={index > 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),
});
}}
/>
);
})}
</View>
{pendingRemoveConnection ? (
<AdaptiveModalSheet
title="Remove connection"
visible
onClose={() => {
if (isRemovingConnection) return;
setPendingRemoveConnection(null);
}}
testID="remove-connection-confirm-modal"
>
<Text style={styles.confirmText}>
Remove {pendingRemoveConnection.title}? This cannot be undone.
</Text>
<View style={styles.confirmActions}>
<Button
variant="secondary"
size="sm"
style={{ flex: 1 }}
onPress={() => setPendingRemoveConnection(null)}
disabled={isRemovingConnection}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
style={{ flex: 1 }}
onPress={() => {
const { connectionId } = pendingRemoveConnection;
setIsRemovingConnection(true);
void removeConnection(host.serverId, connectionId)
.then(() => setPendingRemoveConnection(null))
.catch((error) => {
console.error("[HostPage] Failed to remove connection", error);
Alert.alert("Error", "Unable to remove connection");
})
.finally(() => setIsRemovingConnection(false));
}}
disabled={isRemovingConnection}
testID="remove-connection-confirm"
>
Remove
</Button>
</View>
</AdaptiveModalSheet>
) : null}
</SettingsSection>
);
}
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 (
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{title}
</Text>
</View>
<Text style={[styles.connectionLatency, { color: latencyColor }]}>{latencyText}</Text>
<Button
variant="ghost"
size="sm"
textStyle={{ color: theme.colors.destructive }}
onPress={onRemove}
>
Remove
</Button>
</View>
);
}
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
return (
<>
<SettingsSection title="Operations">
<RestartDaemonCard host={host} />
<InjectPaseoToolsCard serverId={host.serverId} />
</SettingsSection>
{isLocalDaemon ? (
<SettingsSection title="Pair devices">
<PairDeviceRow />
</SettingsSection>
) : null}
{isLocalDaemon ? <LocalDaemonSection /> : null}
</>
);
}
const delay = (ms: number) =>
new Promise<void>((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 (
<View style={settingsStyles.card} testID="host-page-restart-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Restart daemon</Text>
<Text style={settingsStyles.rowHint}>
Restarts the daemon process. The app will reconnect automatically.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleRestart}
disabled={isRestarting || !daemonClient || !isConnected}
testID="host-page-restart-button"
>
{isRestarting ? "Restarting..." : "Restart"}
</Button>
</View>
</View>
);
}
function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
if (!isConnected) return null;
return (
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Inject Paseo tools</Text>
<Text style={settingsStyles.rowHint}>
Automatically inject Paseo MCP tools into new agents
</Text>
</View>
<SegmentedControl
size="sm"
value={config?.mcp.injectIntoAgents === false ? "off" : "on"}
onValueChange={(value) => {
void patchConfig({
mcp: {
injectIntoAgents: value === "on",
},
});
}}
options={[
{ value: "on", label: "On" },
{ value: "off", label: "Off" },
]}
/>
</View>
</View>
);
}
function PairDeviceRow() {
const { theme } = useUnistyles();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<View style={settingsStyles.card}>
<Pressable
style={settingsStyles.row}
onPress={() => setIsModalOpen(true)}
accessibilityRole="button"
testID="host-page-pair-device-row"
>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair a device</Text>
<Text style={settingsStyles.rowHint}>
Scan a QR code or copy a link to connect your phone to this host.
</Text>
</View>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<PairDeviceModal
visible={isModalOpen}
onClose={() => setIsModalOpen(false)}
testID="host-page-pair-device-card"
/>
</View>
);
}
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 (
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Remove host</Text>
<Text style={settingsStyles.rowHint}>
Removes this host and its saved connections from this device.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />}
textStyle={{ color: theme.colors.destructive }}
onPress={() => setIsConfirming(true)}
testID="host-page-remove-host-button"
>
Remove
</Button>
</View>
</View>
{isConfirming ? (
<AdaptiveModalSheet
title="Remove host"
visible
onClose={() => {
if (isRemoving) return;
setIsConfirming(false);
}}
testID="remove-host-confirm-modal"
>
<Text style={styles.confirmText}>
Remove {host.label}? This will delete its saved connections.
</Text>
<View style={styles.confirmActions}>
<Button
variant="secondary"
size="sm"
style={{ flex: 1 }}
onPress={() => setIsConfirming(false)}
disabled={isRemoving}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
style={{ flex: 1 }}
onPress={() => {
setIsRemoving(true);
void removeHost(host.serverId)
.then(() => {
setIsConfirming(false);
onRemoved?.();
})
.catch((error) => {
console.error("[HostPage] Failed to remove host", error);
Alert.alert("Error", "Unable to remove host");
})
.finally(() => setIsRemoving(false));
}}
disabled={isRemoving}
testID="remove-host-confirm"
>
Remove
</Button>
</View>
</AdaptiveModalSheet>
) : null}
</SettingsSection>
);
}
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],
},
}));
@@ -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 (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
<SettingsSection title="Shortcuts">
<View style={[settingsStyles.card, styles.mobileCard]}>
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop.</Text>
</View>
</View>
</SettingsSection>
);
}
return (
<View style={settingsStyles.section}>
<View style={settingsStyles.sectionHeader}>
<Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
{hasOverrides && (
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
Reset all
</Button>
)}
</View>
const resetAllButton = hasOverrides ? (
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
Reset all
</Button>
) : undefined;
{sections.map(function (section) {
return (
<>
{sections.map(function (section, sectionIndex) {
return (
<View key={section.id}>
<Text style={styles.subsectionTitle}>{section.title}</Text>
<SettingsSection
key={section.id}
title={section.title}
trailing={sectionIndex === 0 ? resetAllButton : undefined}
>
<View style={settingsStyles.card}>
{section.rows.map(function (row, index) {
const bindingId = getBindingIdForAction(row.id, {
@@ -233,22 +233,14 @@ export function KeyboardShortcutsSection() {
);
})}
</View>
</View>
</SettingsSection>
);
})}
</View>
</>
);
}
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",
@@ -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<string | null>(null);
const providerDefinitions = buildProviderDefinitions(entries);
const providerRefreshInFlight =
isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
const hasServer = serverId.length > 0;
const refreshAction =
hasServer && isConnected ? (
<Pressable
onPress={() => void refresh()}
disabled={providerRefreshInFlight}
hitSlop={8}
style={settingsStyles.sectionHeaderLink}
accessibilityRole="button"
accessibilityLabel="Refresh providers"
>
<SpinningRefreshIcon
spinning={providerRefreshInFlight}
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</Pressable>
) : undefined;
return (
<>
<SettingsSection
title="Providers"
trailing={refreshAction}
testID="host-page-providers-card"
style={styles.sectionSpacing}
>
{!hasServer || !isConnected ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Connect to this host to see providers</Text>
</View>
) : isLoading ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Loading...</Text>
</View>
) : (
<View style={settingsStyles.card}>
{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 (
<Pressable
key={def.id}
style={[settingsStyles.row, index > 0 && settingsStyles.rowBorder]}
onPress={() => setDiagnosticProvider(def.id)}
accessibilityRole="button"
>
<View style={settingsStyles.rowContent}>
<View style={styles.titleRow}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
<Text style={settingsStyles.rowTitle}>{def.label}</Text>
</View>
{providerError ? (
<Text style={styles.errorText} numberOfLines={3}>
{providerError}
</Text>
) : null}
{status === "ready" && modelCount > 0 ? (
<Text style={settingsStyles.rowHint}>
{modelCount === 1 ? "1 model" : `${modelCount} models`}
</Text>
) : null}
</View>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready" ? "success" : status === "error" ? "error" : "muted"
}
/>
</Pressable>
);
})}
</View>
)}
</SettingsSection>
{diagnosticProvider ? (
<ProviderDiagnosticSheet
provider={diagnosticProvider}
visible
onClose={() => 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],
},
}));
@@ -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<ViewStyle>;
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 `<Text>` headers or bare card margins.
*/
export function SettingsSection({
title,
trailing,
testID,
style,
children,
}: SettingsSectionProps) {
return (
<View style={[settingsStyles.section, style]} testID={testID}>
<View style={styles.header}>
<Text style={settingsStyles.sectionHeaderTitle}>{title}</Text>
{trailing}
</View>
<View style={styles.content}>{children}</View>
</View>
);
}
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],
},
}));
+29 -6
View File
@@ -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;