Merge branch 'config-work' into dev
This commit is contained in:
@@ -0,0 +1,166 @@
|
|||||||
|
# Unistyles Gotchas
|
||||||
|
|
||||||
|
This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.
|
||||||
|
|
||||||
|
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
|
||||||
|
|
||||||
|
## How Updates Propagate
|
||||||
|
|
||||||
|
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
|
||||||
|
|
||||||
|
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
|
||||||
|
|
||||||
|
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
|
||||||
|
|
||||||
|
## Main Gotcha: `contentContainerStyle`
|
||||||
|
|
||||||
|
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||||
|
|
||||||
|
Avoid this pattern when the style depends on the theme:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ScrollView contentContainerStyle={styles.container} />
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
|
||||||
|
|
||||||
|
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, and library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
|
||||||
|
|
||||||
|
## Fix Patterns
|
||||||
|
|
||||||
|
Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView contentContainerStyle={styles.contentContainer}>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
|
||||||
|
|
||||||
|
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ScrollView } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
|
const ThemedScrollView = withUnistyles(ScrollView);
|
||||||
|
|
||||||
|
<ThemedScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
|
||||||
|
|
||||||
|
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.contentContainer,
|
||||||
|
{ backgroundColor: theme.colors.surface0 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
||||||
|
|
||||||
|
## Hidden Sheet Content
|
||||||
|
|
||||||
|
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
|
||||||
|
|
||||||
|
We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
<Text style={[styles.title, { color: theme.colors.foreground }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
|
||||||
|
|
||||||
|
## Adaptive Themes And Persisted Settings
|
||||||
|
|
||||||
|
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
|
||||||
|
|
||||||
|
Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:
|
||||||
|
|
||||||
|
1. Unistyles config starts with `adaptiveThemes: true`.
|
||||||
|
2. The device may report system light.
|
||||||
|
3. Settings load a persisted non-auto preference, such as dark.
|
||||||
|
4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`.
|
||||||
|
|
||||||
|
That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.
|
||||||
|
|
||||||
|
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
[
|
||||||
|
"react-native-unistyles/plugin",
|
||||||
|
{
|
||||||
|
root: "src",
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild the bundle and look for lines such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/components/welcome-screen.tsx: styles.container: [Theme]
|
||||||
|
```
|
||||||
|
|
||||||
|
This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.
|
||||||
|
|
||||||
|
For paint-layer bugs, use high-contrast probes:
|
||||||
|
|
||||||
|
1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta.
|
||||||
|
2. Cold-restart the app, not just Fast Refresh.
|
||||||
|
3. Screenshot the simulator and sample pixels to see which color fills the area.
|
||||||
|
4. Remove the probes before committing.
|
||||||
|
|
||||||
|
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Unistyles v3 documentation](https://www.unistyl.es/)
|
||||||
|
- [Theming: initial theme, adaptive themes, and runtime theme changes](https://www.unistyl.es/v3/guides/theming)
|
||||||
|
- [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue)
|
||||||
|
- [withUnistyles reference](https://www.unistyl.es/v3/references/with-unistyles)
|
||||||
|
- [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views)
|
||||||
|
- [Babel plugin debug option](https://www.unistyl.es/v3/other/babel-plugin#debug)
|
||||||
|
- [Why my view doesn't update?](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update)
|
||||||
|
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
|
||||||
|
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
|
||||||
|
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
|
||||||
|
- [Local research note: welcome theme split](</Users/moboudra/.paseo/notes/welcome-theme-split-research.md>)
|
||||||
@@ -200,16 +200,11 @@ export const gotoHome = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const openSettings = 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.
|
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
||||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||||
await expect(settingsButton).toBeVisible();
|
await expect(settingsButton).toBeVisible();
|
||||||
await settingsButton.click();
|
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) => {
|
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -404,6 +404,12 @@ function AppContainer({
|
|||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
|
const pathname = usePathname();
|
||||||
|
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
||||||
|
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
||||||
|
// global concerns like keyboard shortcuts. Split those out so settings (and
|
||||||
|
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
|
||||||
|
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bp = UnistylesRuntime.breakpoint;
|
const bp = UnistylesRuntime.breakpoint;
|
||||||
@@ -435,7 +441,7 @@ function AppContainer({
|
|||||||
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: keyboardShortcutsEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
@@ -805,7 +811,8 @@ function RootStack() {
|
|||||||
>
|
>
|
||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Protected guard={storeReady}>
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="welcome" />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings/index" />
|
||||||
|
<Stack.Screen name="settings/[section]" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -822,6 +829,7 @@ function RootStack() {
|
|||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="settings/hosts/[serverId]" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
|
||||||
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;
|
|
||||||
|
|
||||||
export default function HostIndexRoute() {
|
export default function HostIndexRoute() {
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<HostIndexRouteContent />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HostIndexRouteContent() {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const { isLoading: preferencesLoading } = useFormPreferences();
|
if (!serverId) return null;
|
||||||
const sessionAgents = useSessionStore((state) =>
|
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
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 (
|
return (
|
||||||
<HostRouteBootstrapBoundary>
|
<HostRouteBootstrapBoundary>
|
||||||
<SettingsScreen />
|
<Redirect href={href} />
|
||||||
</HostRouteBootstrapBoundary>
|
</HostRouteBootstrapBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,35 +5,19 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult } 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 { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
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 { isWeb } from "@/constants/platform";
|
||||||
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
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: {
|
body: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
@@ -140,63 +124,32 @@ export default function PairScanScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
source?: string;
|
source?: string;
|
||||||
sourceServerId?: string;
|
|
||||||
targetServerId?: string;
|
|
||||||
}>();
|
}>();
|
||||||
const source = typeof params.source === "string" ? params.source : "settings";
|
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 { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const lastScannedRef = useRef<string | null>(null);
|
const lastScannedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const returnToSource = useCallback(
|
const navigateToPairedHost = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
if (source === "onboarding") {
|
if (source === "onboarding") {
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source === "editHost" && targetServerId) {
|
router.replace(buildSettingsHostRoute(serverId));
|
||||||
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, source, sourceServerId, targetServerId],
|
[router, source],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeToSource = useCallback(() => {
|
const closeToSource = useCallback(() => {
|
||||||
if (source === "editHost" && targetServerId) {
|
|
||||||
const settingsServerId = sourceServerId ?? targetServerId;
|
|
||||||
router.replace({
|
|
||||||
pathname: buildHostSettingsRoute(settingsServerId),
|
|
||||||
params: { editHost: targetServerId },
|
|
||||||
} as any);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
if (sourceServerId) {
|
|
||||||
router.replace(buildHostSettingsRoute(sourceServerId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace("/" as any);
|
router.replace("/" as any);
|
||||||
}
|
}
|
||||||
}, [router, source, sourceServerId, targetServerId]);
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) return;
|
if (isWeb) return;
|
||||||
@@ -220,15 +173,6 @@ export default function PairScanScreen() {
|
|||||||
const offerPayload = decodeOfferFragmentPayload(encoded);
|
const offerPayload = decodeOfferFragmentPayload(encoded);
|
||||||
const offer = ConnectionOfferSchema.parse(offerPayload);
|
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(
|
const { client, hostname } = await connectToDaemon(
|
||||||
{
|
{
|
||||||
id: "probe",
|
id: "probe",
|
||||||
@@ -242,7 +186,7 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||||
|
|
||||||
returnToSource(profile.serverId);
|
navigateToPairedHost(profile.serverId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastScannedRef.current = null;
|
lastScannedRef.current = null;
|
||||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||||
@@ -251,18 +195,13 @@ export default function PairScanScreen() {
|
|||||||
setIsPairing(false);
|
setIsPairing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<BackHeader title="Scan QR" onBack={() => router.back()} />
|
||||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
|
||||||
<Pressable onPress={() => router.back()}>
|
|
||||||
<Text style={styles.headerButtonText}>Close</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
||||||
<View style={styles.permissionCard}>
|
<View style={styles.permissionCard}>
|
||||||
<Text style={styles.permissionTitle}>Not available on web</Text>
|
<Text style={styles.permissionTitle}>Not available on web</Text>
|
||||||
@@ -282,12 +221,7 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<BackHeader title="Scan QR" onBack={closeToSource} />
|
||||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
|
||||||
<Pressable onPress={closeToSource}>
|
|
||||||
<Text style={styles.headerButtonText}>Close</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
||||||
{!granted ? (
|
{!granted ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" }} />;
|
||||||
|
}
|
||||||
@@ -85,7 +85,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
@@ -251,7 +250,7 @@ export function AdaptiveModalSheet({
|
|||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader} testID={testID}>
|
<View style={styles.bottomSheetHeader} testID={testID}>
|
||||||
<View style={styles.headerTitleGroup}>
|
<View style={styles.headerTitleGroup}>
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
@@ -280,7 +279,7 @@ export function AdaptiveModalSheet({
|
|||||||
<>
|
<>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.headerTitleGroup}>
|
<View style={styles.headerTitleGroup}>
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function AddHostMethodModal({
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handleDirect}
|
onPress={handleDirect}
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Direct connection"
|
accessibilityLabel="Direct connection"
|
||||||
testID="add-host-method-direct"
|
testID="add-host-method-direct"
|
||||||
>
|
>
|
||||||
@@ -81,7 +82,12 @@ export function AddHostMethodModal({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{isNative ? (
|
{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} />
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
<Text style={styles.optionText}>Scan QR code</Text>
|
<Text style={styles.optionText}>Scan QR code</Text>
|
||||||
@@ -93,6 +99,7 @@ export function AddHostMethodModal({
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handlePaste}
|
onPress={handlePaste}
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Paste pairing link"
|
accessibilityLabel="Paste pairing link"
|
||||||
testID="add-host-method-pair-link"
|
testID="add-host-method-pair-link"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ function buildConnectionFailureCopy(
|
|||||||
export interface AddHostModalProps {
|
export interface AddHostModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
targetServerId?: string;
|
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onSaved?: (result: {
|
onSaved?: (result: {
|
||||||
profile: HostProfile;
|
profile: HostProfile;
|
||||||
@@ -142,13 +141,7 @@ export interface AddHostModalProps {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddHostModal({
|
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
onCancel,
|
|
||||||
onSaved,
|
|
||||||
targetServerId,
|
|
||||||
}: AddHostModalProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertDirectConnection } = useHostMutations();
|
const { upsertDirectConnection } = useHostMutations();
|
||||||
@@ -211,15 +204,6 @@ export function AddHostModal({
|
|||||||
endpoint,
|
endpoint,
|
||||||
});
|
});
|
||||||
await client.close().catch(() => undefined);
|
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 isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||||
const profile = await upsertDirectConnection({
|
const profile = await upsertDirectConnection({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -245,7 +229,7 @@ export function AddHostModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]);
|
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -8,6 +8,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||||
|
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||||
|
|
||||||
interface BranchSwitcherProps {
|
interface BranchSwitcherProps {
|
||||||
currentBranchName: string | null;
|
currentBranchName: string | null;
|
||||||
@@ -46,9 +47,7 @@ export function BranchSwitcher({
|
|||||||
const titleContent = (
|
const titleContent = (
|
||||||
<>
|
<>
|
||||||
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,15 +101,6 @@ export function BranchSwitcher({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
headerTitle: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: {
|
|
||||||
xs: "400",
|
|
||||||
md: "300",
|
|
||||||
},
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
|
||||||
branchSwitcherTrigger: {
|
branchSwitcherTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Pressable, Text } from "react-native";
|
import { Pressable } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ArrowLeft } from "lucide-react-native";
|
import { ArrowLeft } from "lucide-react-native";
|
||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
|
import { ScreenTitle } from "./screen-title";
|
||||||
|
|
||||||
interface BackHeaderProps {
|
interface BackHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
titleAccessory?: ReactNode;
|
||||||
rightContent?: ReactNode;
|
rightContent?: ReactNode;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
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} />
|
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{title && (
|
{title && <ScreenTitle>{title}</ScreenTitle>}
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
{titleAccessory}
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={rightContent}
|
right={rightContent}
|
||||||
@@ -45,13 +49,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
borderRadius: theme.borderRadius.lg,
|
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) => {
|
||||||
onPress(e);
|
onPress(e);
|
||||||
}}
|
}}
|
||||||
style={[styles.button, style]}
|
style={[headerIconSlotStyle.slot, style]}
|
||||||
>
|
>
|
||||||
{typeof children === "function"
|
{typeof children === "function"
|
||||||
? (state: { pressed: boolean; hovered?: boolean }) =>
|
? (state: { pressed: boolean; hovered?: boolean }) =>
|
||||||
@@ -68,14 +68,17 @@ export function HeaderToggleButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
export const headerIconSlotStyle = StyleSheet.create((theme) => ({
|
||||||
button: {
|
slot: {
|
||||||
padding: {
|
padding: {
|
||||||
xs: theme.spacing[3],
|
xs: theme.spacing[3],
|
||||||
md: theme.spacing[2],
|
md: theme.spacing[2],
|
||||||
},
|
},
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
tooltipRow: {
|
tooltipRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ReactNode } from "react";
|
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { PanelLeft } from "lucide-react-native";
|
import { PanelLeft } from "lucide-react-native";
|
||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
|
import { ScreenTitle } from "./screen-title";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -83,11 +84,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
|
|||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
<SidebarMenuToggle />
|
<SidebarMenuToggle />
|
||||||
{title && (
|
{title && <ScreenTitle>{title}</ScreenTitle>}
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={rightContent}
|
right={rightContent}
|
||||||
@@ -101,15 +98,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
left: {
|
left: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
title: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: {
|
|
||||||
xs: "400",
|
|
||||||
md: "300",
|
|
||||||
},
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
mobileMenuIcon: {
|
mobileMenuIcon: {
|
||||||
width: MOBILE_MENU_LINE_WIDTH,
|
width: MOBILE_MENU_LINE_WIDTH,
|
||||||
height: 12,
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -33,6 +33,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
|||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
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 { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
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 { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import {
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
HEADER_INNER_HEIGHT,
|
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
|
||||||
useIsCompactFormFactor,
|
|
||||||
} from "@/constants/layout";
|
|
||||||
import {
|
import {
|
||||||
buildHostSessionsRoute,
|
buildHostSessionsRoute,
|
||||||
buildHostSettingsRoute,
|
buildSettingsRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -220,19 +217,13 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
}, [openProjectPicker]);
|
}, [openProjectPicker]);
|
||||||
|
|
||||||
const handleSettingsMobile = useCallback(() => {
|
const handleSettingsMobile = useCallback(() => {
|
||||||
if (!activeServerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeToAgent();
|
closeToAgent();
|
||||||
router.push(buildHostSettingsRoute(activeServerId));
|
router.push(buildSettingsRoute());
|
||||||
}, [activeServerId, closeToAgent]);
|
}, [closeToAgent]);
|
||||||
|
|
||||||
const handleSettingsDesktop = useCallback(() => {
|
const handleSettingsDesktop = useCallback(() => {
|
||||||
if (!activeServerId) {
|
router.push(buildSettingsRoute());
|
||||||
return;
|
}, []);
|
||||||
}
|
|
||||||
router.push(buildHostSettingsRoute(activeServerId));
|
|
||||||
}, [activeServerId]);
|
|
||||||
|
|
||||||
const handleViewMoreNavigate = useCallback(() => {
|
const handleViewMoreNavigate = useCallback(() => {
|
||||||
if (!activeServerId) {
|
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({
|
function MobileSidebar({
|
||||||
theme,
|
theme,
|
||||||
activeServerId,
|
activeServerId,
|
||||||
@@ -394,6 +347,8 @@ function MobileSidebar({
|
|||||||
handleViewMoreNavigate,
|
handleViewMoreNavigate,
|
||||||
}: MobileSidebarProps) {
|
}: MobileSidebarProps) {
|
||||||
const newAgentKeys = useShortcutKeys("new-agent");
|
const newAgentKeys = useShortcutKeys("new-agent");
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -544,11 +499,13 @@ function MobileSidebar({
|
|||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
>
|
>
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
<View style={styles.sidebarHeader}>
|
<SidebarHeaderRow
|
||||||
<View style={styles.sidebarHeaderRow}>
|
icon={MessagesSquare}
|
||||||
<SessionsButton onPress={handleViewMore} />
|
label="Sessions"
|
||||||
</View>
|
onPress={handleViewMore}
|
||||||
</View>
|
isActive={isSessionsActive}
|
||||||
|
testID="sidebar-sessions"
|
||||||
|
/>
|
||||||
|
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
<SidebarAgentListSkeleton />
|
<SidebarAgentListSkeleton />
|
||||||
@@ -676,6 +633,8 @@ function DesktopSidebar({
|
|||||||
handleViewMore,
|
handleViewMore,
|
||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const newAgentKeys = useShortcutKeys("new-agent");
|
const newAgentKeys = useShortcutKeys("new-agent");
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const padding = useWindowControlsPadding("sidebar");
|
const padding = useWindowControlsPadding("sidebar");
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
@@ -732,11 +691,13 @@ function DesktopSidebar({
|
|||||||
<View style={styles.sidebarDragArea}>
|
<View style={styles.sidebarDragArea}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||||
<View style={styles.sidebarHeader}>
|
<SidebarHeaderRow
|
||||||
<View style={styles.sidebarHeaderRow}>
|
icon={MessagesSquare}
|
||||||
<SessionsButton onPress={handleViewMore} />
|
label="Sessions"
|
||||||
</View>
|
onPress={handleViewMore}
|
||||||
</View>
|
isActive={isSessionsActive}
|
||||||
|
testID="sidebar-sessions"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
@@ -881,46 +842,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sidebarDragArea: {
|
sidebarDragArea: {
|
||||||
position: "relative",
|
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: {
|
hostTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
export interface PairLinkModalProps {
|
export interface PairLinkModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
targetServerId?: string;
|
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onSaved?: (result: {
|
onSaved?: (result: {
|
||||||
profile: HostProfile;
|
profile: HostProfile;
|
||||||
@@ -57,13 +56,7 @@ export interface PairLinkModalProps {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PairLinkModal({
|
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
onCancel,
|
|
||||||
onSaved,
|
|
||||||
targetServerId,
|
|
||||||
}: PairLinkModalProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
@@ -128,15 +121,6 @@ export function PairLinkModal({
|
|||||||
return;
|
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 {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
@@ -165,7 +149,7 @@ export function PairLinkModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDaemonFromOfferUrl]);
|
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -181,7 +181,7 @@ export function Button({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
<Text style={resolvedTextStyle}>{children}</Text>
|
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { AddHostModal } from "./add-host-modal";
|
import { AddHostModal } from "./add-host-modal";
|
||||||
import { PairLinkModal } from "./pair-link-modal";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
@@ -20,6 +21,8 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
|||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
|
const ThemedScrollView = withUnistyles(ScrollView);
|
||||||
|
|
||||||
type WelcomeAction = {
|
type WelcomeAction = {
|
||||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||||
label: string;
|
label: string;
|
||||||
@@ -30,6 +33,10 @@ type WelcomeAction = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
container: {
|
container: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
@@ -47,14 +54,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.xl,
|
fontSize: theme.fontSize.xl,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
marginBottom: theme.spacing[3],
|
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.sm,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: theme.spacing[8],
|
},
|
||||||
|
copyBlock: {
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginBottom: theme.spacing[12],
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -117,19 +127,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.destructive,
|
color: theme.colors.destructive,
|
||||||
fontSize: theme.fontSize.sm,
|
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: {
|
setupLink: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
gap: 6,
|
gap: 6,
|
||||||
marginBottom: theme.spacing[6],
|
|
||||||
},
|
},
|
||||||
setupLinkText: {
|
setupLinkText: {
|
||||||
color: theme.colors.accent,
|
color: theme.colors.accent,
|
||||||
@@ -142,6 +144,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginTop: theme.spacing[6],
|
marginTop: theme.spacing[6],
|
||||||
},
|
},
|
||||||
|
settingsButton: {
|
||||||
|
alignSelf: "center",
|
||||||
|
marginTop: theme.spacing[6],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||||
@@ -243,13 +249,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentPathname = typeof window === "undefined" ? null : window.location.pathname || null;
|
if (!anyOnlineServerId) return;
|
||||||
if (currentPathname && currentPathname !== "/welcome") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!anyOnlineServerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(buildHostRootRoute(anyOnlineServerId));
|
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||||
}, [anyOnlineServerId, router]);
|
}, [anyOnlineServerId, router]);
|
||||||
|
|
||||||
@@ -309,8 +309,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ThemedScrollView
|
||||||
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
styles.container,
|
styles.container,
|
||||||
{ paddingBottom: theme.spacing[6] + insets.bottom },
|
{ paddingBottom: theme.spacing[6] + insets.bottom },
|
||||||
@@ -320,22 +320,25 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
>
|
>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<PaseoLogo size={96} />
|
<PaseoLogo size={96} />
|
||||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
<View style={styles.copyBlock}>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
{showHostList ? (
|
||||||
</Text>
|
<Text style={styles.subtitle}>Connecting to your hosts…</Text>
|
||||||
|
) : (
|
||||||
{!showHostList && isNative && (
|
<>
|
||||||
<>
|
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||||
<Text style={styles.setupHint}>
|
{isNative ? (
|
||||||
You need the Paseo desktop app or server running on your computer first.
|
<Pressable
|
||||||
</Text>
|
style={styles.setupLink}
|
||||||
<Pressable style={styles.setupLink} onPress={() => openExternalUrl("https://paseo.sh")}>
|
onPress={() => openExternalUrl("https://paseo.sh")}
|
||||||
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
|
>
|
||||||
<ExternalLink size={14} color={theme.colors.accent} />
|
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
||||||
</Pressable>
|
<ExternalLink size={14} color={theme.colors.accent} />
|
||||||
</>
|
</Pressable>
|
||||||
)}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
@@ -366,6 +369,17 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={Settings}
|
||||||
|
onPress={() => router.push("/settings")}
|
||||||
|
style={styles.settingsButton}
|
||||||
|
testID="welcome-open-settings"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
<Text style={styles.versionLabel}>{appVersionText}</Text>
|
||||||
|
|
||||||
@@ -386,6 +400,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
finishOnboarding(serverId);
|
finishOnboarding(serverId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ThemedScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { View, Text } from "react-native";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Check } from "lucide-react-native";
|
import { Check } from "lucide-react-native";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import type { DesktopPermissionStatus } from "@/desktop/permissions/desktop-permissions";
|
import type { DesktopPermissionStatus } from "@/desktop/permissions/desktop-permissions";
|
||||||
|
|
||||||
export interface DesktopPermissionRowProps {
|
export interface DesktopPermissionRowProps {
|
||||||
@@ -38,9 +39,9 @@ export function DesktopPermissionRow({
|
|||||||
state !== "not-granted";
|
state !== "not-granted";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.audioRow, showBorder && styles.audioRowBorder]}>
|
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
|
||||||
<View style={styles.audioRowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={styles.audioRowTitle}>{title}</Text>
|
<Text style={settingsStyles.rowTitle}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.permissionRowActions}>
|
<View style={styles.permissionRowActions}>
|
||||||
{isGranted ? (
|
{isGranted ? (
|
||||||
@@ -74,25 +75,6 @@ export function DesktopPermissionRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
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: {
|
permissionRowActions: {
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
@@ -117,7 +99,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
permissionStatusText: {
|
permissionStatusText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
permissionDetailText: {
|
permissionDetailText: {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
|
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
|
||||||
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
|
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
|
|
||||||
export function DesktopPermissionsSection() {
|
export function DesktopPermissionsSection() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -27,23 +28,23 @@ export function DesktopPermissionsSection() {
|
|||||||
const isBusy = isRefreshing || requestingPermission !== null;
|
const isBusy = isRefreshing || requestingPermission !== null;
|
||||||
const notificationsGranted = snapshot?.notifications.state === "granted";
|
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 (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<SettingsSection title="Permissions" trailing={refreshButton}>
|
||||||
<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>
|
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
<DesktopPermissionRow
|
<DesktopPermissionRow
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
@@ -74,18 +75,11 @@ export function DesktopPermissionsSection() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
permissionSectionHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
marginBottom: theme.spacing[3],
|
|
||||||
},
|
|
||||||
errorText: {
|
errorText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ActivityIndicator, Alert, Text, View } from "react-native";
|
|||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
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 { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -18,15 +19,12 @@ import {
|
|||||||
stopDesktopDaemon,
|
stopDesktopDaemon,
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||||
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
|
|
||||||
export interface LocalDaemonSectionProps {
|
export function LocalDaemonSection() {
|
||||||
appVersion: string | null;
|
|
||||||
showLifecycleControls: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalDaemonSectionProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const showSection = shouldUseDesktopDaemon();
|
const showSection = shouldUseDesktopDaemon();
|
||||||
|
const appVersion = resolveAppVersion();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
|
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
|
||||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||||
@@ -236,22 +234,26 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<SettingsSection
|
||||||
<View style={settingsStyles.sectionHeader}>
|
title="Daemon"
|
||||||
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
|
trailing={advancedSettingsButton}
|
||||||
<Button
|
testID="host-page-daemon-lifecycle-card"
|
||||||
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>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View style={[settingsStyles.card, styles.loadingCard]}>
|
<View style={[settingsStyles.card, styles.loadingCard]}>
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
@@ -271,61 +273,57 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{showLifecycleControls ? (
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<>
|
<View style={settingsStyles.rowContent}>
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||||
<View style={settingsStyles.rowContent}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
{isDaemonManagementPaused
|
||||||
<Text style={settingsStyles.rowHint}>
|
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||||
{isDaemonManagementPaused
|
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
</Text>
|
||||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
</View>
|
||||||
</Text>
|
<Button
|
||||||
</View>
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant="outline"
|
leftIcon={
|
||||||
size="sm"
|
isDaemonManagementPaused ? (
|
||||||
leftIcon={
|
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||||
isDaemonManagementPaused ? (
|
) : (
|
||||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||||
) : (
|
)
|
||||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
}
|
||||||
)
|
onPress={handleToggleDaemonManagement}
|
||||||
}
|
disabled={isUpdatingDaemonManagement}
|
||||||
onPress={handleToggleDaemonManagement}
|
>
|
||||||
disabled={isUpdatingDaemonManagement}
|
{isUpdatingDaemonManagement
|
||||||
>
|
? isDaemonManagementPaused
|
||||||
{isUpdatingDaemonManagement
|
? "Resuming..."
|
||||||
? isDaemonManagementPaused
|
: "Pausing..."
|
||||||
? "Resuming..."
|
: isDaemonManagementPaused
|
||||||
: "Pausing..."
|
? "Resume"
|
||||||
: isDaemonManagementPaused
|
: "Pause"}
|
||||||
? "Resume"
|
</Button>
|
||||||
: "Pause"}
|
</View>
|
||||||
</Button>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
</View>
|
<View style={settingsStyles.rowContent}>
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||||
<View style={settingsStyles.rowContent}>
|
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
</View>
|
||||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
<Button
|
||||||
</View>
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant="outline"
|
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
size="sm"
|
onPress={handleUpdateLocalDaemon}
|
||||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
disabled={isRestartingDaemon}
|
||||||
onPress={handleUpdateLocalDaemon}
|
>
|
||||||
disabled={isRestartingDaemon}
|
{isRestartingDaemon
|
||||||
>
|
? daemonStatus?.status === "running"
|
||||||
{isRestartingDaemon
|
? "Restarting..."
|
||||||
? daemonStatus?.status === "running"
|
: "Starting..."
|
||||||
? "Restarting..."
|
: daemonActionLabel}
|
||||||
: "Starting..."
|
</Button>
|
||||||
: daemonActionLabel}
|
</View>
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||||
@@ -423,7 +421,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
</View>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +446,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
valueText: {
|
valueText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
},
|
},
|
||||||
valueSubtext: {
|
valueSubtext: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -482,17 +479,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
lineHeight: 18,
|
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: {
|
modalActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useFocusEffect } from "@react-navigation/native";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native";
|
import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import {
|
import {
|
||||||
@@ -79,39 +80,35 @@ export function IntegrationsSection() {
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<SettingsSection title="Integrations" trailing={trailing}>
|
||||||
<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>
|
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
<View style={settingsStyles.row}>
|
<View style={settingsStyles.row}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
@@ -166,7 +163,7 @@ export function IntegrationsSection() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</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;
|
if (!showSection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<View style={settingsStyles.section} testID="host-page-pair-device-card">
|
||||||
<Text style={settingsStyles.sectionTitle}>Pair device</Text>
|
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
{pairingQuery.isPending ? (
|
{pairingQuery.isPending ? (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "@/utils/command-center-focus-restore";
|
} from "@/utils/command-center-focus-restore";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDetailRoute,
|
buildHostAgentDetailRoute,
|
||||||
buildHostSettingsRoute,
|
buildSettingsRoute,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
@@ -170,9 +170,8 @@ export function useCommandCenter() {
|
|||||||
}, [agents, open, query]);
|
}, [agents, open, query]);
|
||||||
|
|
||||||
const settingsRoute = useMemo<Href>(() => {
|
const settingsRoute = useMemo<Href>(() => {
|
||||||
const serverIdFromPath = activeServerId;
|
return buildSettingsRoute();
|
||||||
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
|
}, []);
|
||||||
}, [activeServerId]);
|
|
||||||
|
|
||||||
const actionItems = useMemo(() => {
|
const actionItems = useMemo(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsLocalDaemon(serverId: string): boolean {
|
export function useLocalDaemonServerId(): string | null {
|
||||||
const normalizedServerId = serverId.trim();
|
|
||||||
const isDesktopApp = shouldUseDesktopDaemon();
|
const isDesktopApp = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@@ -32,9 +31,20 @@ export function useIsLocalDaemon(serverId: string): boolean {
|
|||||||
retry: false,
|
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 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 { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import {
|
import {
|
||||||
buildHostSettingsRoute,
|
buildSettingsRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
@@ -245,14 +245,11 @@ export function useKeyboardShortcuts({
|
|||||||
toggleAgentList();
|
toggleAgentList();
|
||||||
return true;
|
return true;
|
||||||
case "settings.toggle":
|
case "settings.toggle":
|
||||||
if (pathname.endsWith("/settings")) {
|
if (pathname.startsWith("/settings")) {
|
||||||
router.back();
|
router.back();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!activeServerId) {
|
router.push(buildSettingsRoute());
|
||||||
return false;
|
|
||||||
}
|
|
||||||
router.push(buildHostSettingsRoute(activeServerId));
|
|
||||||
return true;
|
return true;
|
||||||
case "sidebar.toggle.both":
|
case "sidebar.toggle.both":
|
||||||
if (toggleBothSidebars) {
|
if (toggleBothSidebars) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
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 { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
@@ -15,12 +15,16 @@ import {
|
|||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
|
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||||
|
|
||||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||||
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
||||||
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
||||||
|
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||||
|
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
|
||||||
@@ -55,8 +59,23 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
>
|
>
|
||||||
Add a project
|
Add a project
|
||||||
</Button>
|
</Button>
|
||||||
|
{isLocalDaemon ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={Smartphone}
|
||||||
|
onPress={() => setIsPairDeviceOpen(true)}
|
||||||
|
testID="open-project-pair-device"
|
||||||
|
>
|
||||||
|
Pair device
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<PairDeviceModal
|
||||||
|
visible={isPairDeviceOpen}
|
||||||
|
onClose={() => setIsPairDeviceOpen(false)}
|
||||||
|
testID="open-project-pair-device-modal"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,6 +107,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
cta: {
|
cta: {
|
||||||
marginTop: theme.spacing[12],
|
marginTop: theme.spacing[12],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
heading: {
|
heading: {
|
||||||
color: theme.colors.foreground,
|
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 { useIsFocused } from "@react-navigation/native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
@@ -176,30 +177,29 @@ export function KeyboardShortcutsSection() {
|
|||||||
|
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
return (
|
return (
|
||||||
<View style={settingsStyles.section}>
|
<SettingsSection title="Shortcuts">
|
||||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
|
||||||
<View style={[settingsStyles.card, styles.mobileCard]}>
|
<View style={[settingsStyles.card, styles.mobileCard]}>
|
||||||
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop.</Text>
|
<Text style={styles.mobileText}>Keyboard shortcuts are only available on desktop.</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const resetAllButton = hasOverrides ? (
|
||||||
<View style={settingsStyles.section}>
|
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
||||||
<View style={settingsStyles.sectionHeader}>
|
Reset all
|
||||||
<Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
|
</Button>
|
||||||
{hasOverrides && (
|
) : undefined;
|
||||||
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
|
||||||
Reset all
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{sections.map(function (section) {
|
return (
|
||||||
|
<>
|
||||||
|
{sections.map(function (section, sectionIndex) {
|
||||||
return (
|
return (
|
||||||
<View key={section.id}>
|
<SettingsSection
|
||||||
<Text style={styles.subsectionTitle}>{section.title}</Text>
|
key={section.id}
|
||||||
|
title={section.title}
|
||||||
|
trailing={sectionIndex === 0 ? resetAllButton : undefined}
|
||||||
|
>
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
{section.rows.map(function (row, index) {
|
{section.rows.map(function (row, index) {
|
||||||
const bindingId = getBindingIdForAction(row.id, {
|
const bindingId = getBindingIdForAction(row.id, {
|
||||||
@@ -233,22 +233,14 @@ export function KeyboardShortcutsSection() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
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: {
|
row: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
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],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -372,12 +372,35 @@ export function buildHostNewWorkspaceRoute(
|
|||||||
return `${base}/new?${params.toString()}` as const;
|
return `${base}/new?${params.toString()}` as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostSettingsRoute(serverId: string) {
|
export const SETTINGS_SECTION_SLUGS = [
|
||||||
const base = buildHostRootRoute(serverId);
|
"general",
|
||||||
if (base === "/") {
|
"shortcuts",
|
||||||
return "/" as const;
|
"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) {
|
export function mapPathnameToServer(pathname: string, nextServerId: string) {
|
||||||
@@ -389,7 +412,7 @@ export function mapPathnameToServer(pathname: string, nextServerId: string) {
|
|||||||
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
|
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
|
||||||
const base = buildHostRootRoute(normalized);
|
const base = buildHostRootRoute(normalized);
|
||||||
if (suffix.startsWith("settings")) {
|
if (suffix.startsWith("settings")) {
|
||||||
return `${base}/settings` as const;
|
return buildSettingsHostRoute(normalized);
|
||||||
}
|
}
|
||||||
if (suffix.startsWith("sessions")) {
|
if (suffix.startsWith("sessions")) {
|
||||||
return `${base}/sessions` as const;
|
return `${base}/sessions` as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user