Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] d2fca2d62d fix(claude): retain final usage when a turn fails
Non-success SDK results computed final token counts and cost but emitted only failure. Publish the existing usage update before the terminal event so clients retain completed work accounting.
2026-11-02 03:42:51 +00:00
40 changed files with 489 additions and 843 deletions
+8
View File
@@ -406,7 +406,10 @@
{
"files": [
"packages/app/src/app/_layout.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
@@ -425,6 +428,7 @@
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/pair-link-modal.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
@@ -539,10 +543,14 @@
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
+4 -4
View File
@@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
Weight has three tiers, applied by role:
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
@@ -114,7 +114,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`).
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
@@ -278,7 +278,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
-6
View File
@@ -58,12 +58,6 @@ restore the same workspace and could repeat the crash.
## App-Wide Route Hops
Keep Add host in `hosts/add-host`, opened through `useAddHost()`. It is an in-place
flow; callers choose what happens after success. Settings opts into the new host’s
Overview. Do not send callers to Settings with an `addHost` query parameter: a
retained route replays that request when appearance changes remount its content.
The flow provider and sheet host stay outside appearance keys so open drafts survive.
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
@@ -5,7 +5,6 @@ import path from "node:path";
import { test, expect } from "../support/fixtures";
import {
addProjectFlow,
chooseAddHostFromProjectFlow,
addProjectFlowBack,
addProjectFlowHost,
addProjectFlowInput,
@@ -20,7 +19,6 @@ import {
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
disconnectAllHostsAndReload,
addConnectedHostAndReload,
addConnectedHostsAndReload,
addOfflineHostAndReload,
@@ -71,19 +69,6 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
await gotoAppShell(page);
await disconnectAllHostsAndReload(page);
await openAddProjectHostSelection(page);
const originalUrl = page.url();
await chooseAddHostFromProjectFlow(page);
await expect(addProjectFlow(page)).toBeHidden();
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
});
test("method selection shows the daemon's available project sources without search", async ({
page,
}) => {
@@ -294,17 +294,10 @@ test("creates, renames, copies, and deletes entries through the file explorer",
await entry("renamed.txt").click({ button: "right" });
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
await expect
.poll(async () => {
const labelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
const iconColor = await deleteAction
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
return labelColor !== "" && iconColor === labelColor;
})
.toBe(true);
const deleteLabelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
@@ -122,14 +122,10 @@ test.describe("New workspace entry points", () => {
});
const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
const originalUrl = page.url();
await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
} finally {
await seeded.cleanup();
}
@@ -1,8 +1,3 @@
import {
openGlobalNewWorkspaceComposer,
fillNewWorkspaceDraft,
expectNewWorkspaceDraft,
} from "../support/helpers/new-workspace";
import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -278,71 +273,3 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
page,
}) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await expect(page).toHaveURL(originalUrl);
await closeAddHostAndChangeTheme(page);
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await expect(page).toHaveURL(originalUrl);
});
async function openSidebarAddHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Hosts", exact: true }).click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
}
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await page.keyboard.press("Control+Alt+t");
}
test("sidebar Add host completes without navigating", async ({ page }) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
});
test("Settings Add host opens the saved host Overview", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
await expectSettingsHeader(page, "Overview");
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
});
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
await gotoAppShell(page);
await openGlobalNewWorkspaceComposer(page);
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
const originalUrl = page.url();
await openNewWorkspaceAddHost(page);
await expect(page).toHaveURL(originalUrl);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
});
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
await page.getByTestId("host-picker-trigger").click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
}
async function connectToTestHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
}
@@ -113,7 +113,3 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
})
.toBe(true);
}
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
}
-22
View File
@@ -273,25 +273,3 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
);
await page.reload();
}
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
await page.evaluate(
({ registryKey, nonceKey, disableSeedKey }) => {
const nonce = localStorage.getItem(nonceKey);
if (!nonce) throw new Error("Expected the e2e seed nonce");
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
for (const host of hosts) {
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
host.preferredConnectionId = "offline";
}
localStorage.setItem(registryKey, JSON.stringify(hosts));
localStorage.setItem(disableSeedKey, nonce);
},
{
registryKey: REGISTRY_KEY,
nonceKey: SEED_NONCE_KEY,
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
);
await page.reload();
}
+6 -9
View File
@@ -1,4 +1,3 @@
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
@@ -466,7 +465,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<AddHostFlowHost />
<AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
@@ -807,6 +805,7 @@ function RootStack() {
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
@@ -891,13 +890,11 @@ function RootAppTree() {
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<AddHostProvider>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</AddHostProvider>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</LucideProvider>
</View>
</GestureHandlerRootView>
@@ -1,14 +1,17 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { openPairScan } from "./pair-scan-model";
import { openPairScan } from "@/hosts/pair-scan-model";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { PairLinkModal } from "./pair-link-modal";
import { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({
@@ -110,26 +113,50 @@ const styles = StyleSheet.create((theme) => ({
},
}));
export function PairScanContent({
onConnected,
onClose,
}: {
onConnected: (serverId: string) => void;
onClose: () => void;
}) {
export default function PairScanScreen() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams<{
source?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const [permission, requestPermission] = useCameraPermissions();
const navigateToPairedHost = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId));
return;
}
router.replace(buildSettingsHostRoute(serverId));
},
[router, source],
);
const [scan] = useState(() =>
openPairScan({
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
onConnected,
importConnectionLink: (link) =>
getHostRuntimeStore().importConnectionLink(
link,
source === "onboarding" ? "hostRoot" : "hostSettings",
),
onConnected: navigateToPairedHost,
}),
);
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
useEffect(() => () => scan.close(), [scan]);
const closeToSource = useCallback(() => {
try {
router.back();
} catch {
router.replace("/" as Href);
}
}, [router]);
useEffect(() => {
if (isWeb) return;
if (permission && permission.granted) return;
@@ -142,29 +169,35 @@ export function PairScanContent({
[scan],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => onConnected(serverId),
[onConnected],
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
);
const handleRequestPermission = useCallback(() => {
void requestPermission();
}, [requestPermission]);
const bodyStyle = useMemo(
() => [styles.body, { paddingBottom: insets.bottom + 24 }],
[insets.bottom],
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
[insets.bottom, theme.spacing],
);
const helperTextStyle = useMemo(
() => [styles.helperText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
if (isWeb) {
return (
<View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
<View style={bodyStyle}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
<Button style={styles.permissionButton} onPress={onClose}>
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
</Button>
<Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
</Pressable>
</View>
</View>
</View>
@@ -175,14 +208,16 @@ export function PairScanContent({
return (
<View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
<View style={bodyStyle}>
{!granted ? (
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
<Button style={styles.permissionButton} onPress={handleRequestPermission}>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
</Button>
</Pressable>
</View>
) : (
<View style={styles.cameraWrap}>
@@ -200,10 +235,10 @@ export function PairScanContent({
<View style={[styles.corner, styles.cornerBR]} />
</View>
{scanState.status === "pairing" ? (
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
) : null}
{scanState.status === "stopped" && scanState.error ? (
<Text style={styles.helperText}>{scanState.error}</Text>
<Text style={helperTextStyle}>{scanState.error}</Text>
) : null}
{scanState.status === "stopped" ? (
<Button
+3 -2
View File
@@ -35,9 +35,10 @@ function SettingsDaemonRedirect() {
}
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string }>();
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
@@ -49,5 +50,5 @@ export default function SettingsSectionRoute() {
);
}
return <SettingsScreen view={view} />;
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
}
@@ -1,10 +1,9 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { isFdroidBuild } from "@/constants/build-profile";
import { isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host";
@@ -85,7 +84,7 @@ export function AddHostMethodModal({
onClose={onClose}
testID="add-host-method-modal"
>
<PressHighlight
<Pressable
style={styles.option}
onPress={handleDirect}
accessibilityRole="button"
@@ -99,10 +98,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.direct.description")}
</Text>
</View>
</PressHighlight>
</Pressable>
{isElectronRuntime() ? (
<PressHighlight
<Pressable
style={styles.option}
onPress={handleRemoteSsh}
accessibilityRole="button"
@@ -116,11 +115,11 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.remoteSsh.description")}
</Text>
</View>
</PressHighlight>
</Pressable>
) : null}
{isNative && !isFdroidBuild ? (
<PressHighlight
<Pressable
style={styles.option}
onPress={handleScan}
accessibilityRole="button"
@@ -133,10 +132,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.scanQr.description")}
</Text>
</View>
</PressHighlight>
</Pressable>
) : null}
<PressHighlight
<Pressable
style={styles.option}
onPress={handlePaste}
accessibilityRole="button"
@@ -150,7 +149,7 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.pasteLink.description")}
</Text>
</View>
</PressHighlight>
</Pressable>
</AdaptiveModalSheet>
);
}
@@ -1,22 +1,17 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { buildDirectConnectionUri } from "@/components/direct-connection-host";
import { PairingTargetTracker } from "./pair-link-credentials";
import { buildDirectConnectionUri } from "./direct-connection-host";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -193,6 +188,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
@@ -211,7 +207,10 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true));
const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo(
@@ -416,8 +415,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsAdvancedOpen(false);
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
return (
<AdaptiveModalSheet
@@ -431,7 +430,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<ThemedAdaptiveTextInput
<AdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel={t("pairing.direct.fields.host")}
@@ -439,6 +438,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-${inputResetKey}`}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -449,7 +449,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<ThemedAdaptiveTextInput
<AdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel={t("pairing.direct.fields.port")}
@@ -457,6 +457,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-port-${inputResetKey}`}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -468,7 +469,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
</View>
<PressHighlight
<Pressable
style={styles.checkboxRow}
onPress={handleToggleUseTls}
disabled={isSaving}
@@ -481,17 +482,17 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<ThemedCheck size={14} />
<Check size={14} color={theme.colors.accentForeground} />
</View>
) : null}
</View>
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</PressHighlight>
</Pressable>
<View style={styles.field}>
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}>
<ThemedAdaptiveTextInput
<AdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel={t("pairing.direct.fields.password")}
@@ -499,6 +500,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-password-${inputResetKey}`}
onChangeText={setPassword}
placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
autoCorrect={false}
@@ -507,7 +509,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<PressHighlight
<Pressable
style={styles.iconButton}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
@@ -519,13 +521,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} />
</PressHighlight>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
<View style={styles.field}>
<PressHighlight
<Pressable
style={styles.advancedToggle}
onPress={handleToggleAdvanced}
disabled={isSaving}
@@ -535,11 +537,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} />
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</PressHighlight>
</Pressable>
{isAdvancedOpen ? (
<ThemedAdaptiveTextInput
<AdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
@@ -547,6 +549,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-uri-${inputResetKey}`}
onChangeText={handleChangeAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -582,23 +585,3 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</AdaptiveModalSheet>
);
}
const ThemedLink2 = withUnistyles(Link2, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedChevronDown = withUnistyles(ChevronDown, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
@@ -1,4 +1,3 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -93,7 +92,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
interface AddProjectFlowProps {
request: AddProjectFlowRequest;
@@ -326,7 +325,6 @@ function setPageStatus(
// The product flow is intentionally one cohesive page-stack state machine.
// eslint-disable-next-line complexity
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const addHost = useAddHost();
const hosts = useHosts();
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
@@ -618,9 +616,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
icon: Plus,
testID: "add-project-flow-add-host",
select: () => {
// The native project Modal must release its window before the root sheet opens.
onClose();
addHost();
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
},
});
}
@@ -719,7 +717,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
directoryPaths,
githubQuery.data,
host,
addHost,
onClose,
openAddedProject,
page,
@@ -1,7 +1,7 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
Text,
View,
type PressableStateCallbackType,
@@ -23,7 +23,7 @@ import {
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal);
@@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({
}
clear();
onClose();
onSaved?.({
...result,
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
});
onClose();
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
const handleTargetChange = useCallback((value: string) => {
targetRef.current = value;
@@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({
onSubmitEditing={handleSubmit}
/>
</View>
<PressHighlight
<Pressable
style={iconButtonStyle}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
@@ -273,7 +273,7 @@ export function AddRemoteSshHostModal({
testID="remote-ssh-password-visibility-toggle"
>
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
</PressHighlight>
</Pressable>
</View>
</Field>
<View style={styles.actions}>
+9 -4
View File
@@ -1,4 +1,3 @@
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
CircleGauge,
@@ -63,7 +62,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsRoute } from "@/utils/host-routes";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -178,8 +177,14 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
router.push(buildSettingsRoute());
}, []);
const handleAddHostMobile = useAddHost();
const handleAddHostDesktop = handleAddHostMobile;
const handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
@@ -1,20 +1,16 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { PairingTargetTracker } from "./pair-link-credentials";
import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
@@ -104,6 +100,7 @@ function PairLinkModalContent({
onCancel,
onSaved,
}: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { beginLinkPairing } = useHostMutations();
@@ -129,7 +126,10 @@ function PairLinkModalContent({
resetPasswordInput();
}, []);
const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
const pairIcon = useMemo(
() => <Link size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -212,7 +212,7 @@ function PairLinkModalContent({
<View style={styles.field}>
<Text style={styles.label}>{t("pairing.link.label")}</Text>
<ThemedAdaptiveTextInput
<AdaptiveTextInput
ref={inputRef}
initialValue={initialUrl}
testID="pair-link-input"
@@ -220,6 +220,7 @@ function PairLinkModalContent({
accessibilityLabel={t("pairing.link.label")}
onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoFocus
autoCapitalize="none"
@@ -234,7 +235,7 @@ function PairLinkModalContent({
<Text style={styles.label}>
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
</Text>
<ThemedAdaptiveTextInput
<AdaptiveTextInput
testID="pair-link-password-input"
resetKey={`pair-link-password-${passwordResetKey}`}
accessibilityLabel={t("pairing.hostPassword.label")}
@@ -273,9 +274,3 @@ function PairLinkModalContent({
</AdaptiveModalSheet>
);
}
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
+48 -7
View File
@@ -1,5 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
@@ -13,7 +12,11 @@ import {
Terminal,
} from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -159,13 +162,20 @@ function useAnyHostOnline(serverIds: string[]): string | null {
);
}
export function WelcomeScreen() {
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
@@ -186,10 +196,23 @@ export function WelcomeScreen() {
router.push("/settings");
}, [router]);
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
router.push("/pair-scan?source=onboarding");
}, [router]);
const handleHostSaved = useCallback(
({ profile }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding();
},
[onHostAdded, finishOnboarding],
);
const actions: WelcomeAction[] =
isWeb || isFdroidBuild
@@ -293,6 +316,24 @@ export function WelcomeScreen() {
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
<AddHostModal
visible={isDirectOpen}
onClose={handleCloseDirect}
onSaved={handleHostSaved}
/>
<AddRemoteSshHostModal
visible={isRemoteSshOpen}
onClose={handleCloseRemoteSsh}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={handleClosePasteLink}
onSaved={handleHostSaved}
/>
</ScrollView>
</View>
);
-89
View File
@@ -1,89 +0,0 @@
import { createContext, useCallback, useContext, useState, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useStore } from "zustand";
import { AddHostMethodModal } from "./internal/add-host-method-modal";
import { AddHostModal } from "./internal/add-host-modal";
import { AddRemoteSshHostModal } from "./internal/add-remote-ssh-host-modal";
import { PairLinkModal } from "./internal/pair-link-modal";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { PairScanContent } from "./internal/scan";
import { createAddHostFlow, type AddHostOptions, type AddHostSession } from "./internal/model";
const Context = createContext<ReturnType<typeof createAddHostFlow> | null>(null);
/** Owns the flow outside screen and appearance lifetimes. Callers own success navigation. */
export function AddHostProvider({ children }: { children: ReactNode }) {
const [flow] = useState(createAddHostFlow);
return <Context.Provider value={flow}>{children}</Context.Provider>;
}
export function AddHostFlowHost() {
const flow = useContext(Context);
if (!flow) throw new Error("AddHostFlowHost requires AddHostProvider");
const session = useStore(flow.store, (state) => state.session);
return session ? <AddHostSessionView key={session.id} session={session} /> : null;
}
export function useAddHost({ method, onAdded }: AddHostOptions = {}) {
const flow = useContext(Context);
if (!flow) throw new Error("useAddHost requires AddHostProvider");
return useCallback(() => flow.open({ method, onAdded }), [flow, method, onAdded]);
}
function AddHostSessionView({ session }: { session: AddHostSession }) {
const { t } = useTranslation();
const method = useStore(session.step, (state) => state.method);
const back = useCallback(() => session.choose(null), [session]);
const direct = useCallback(() => session.choose("direct"), [session]);
const ssh = useCallback(() => session.choose("ssh"), [session]);
const link = useCallback(() => session.choose("link"), [session]);
const scan = useCallback(() => session.choose("scan"), [session]);
const scanned = useCallback((serverId: string) => session.complete({ serverId }), [session]);
const header = useMemo(
() => ({ title: t("pairing.scan.title"), back: { onPress: back } }),
[t, back],
);
return (
<>
<AddHostMethodModal
visible={method === null}
onClose={session.close}
onDirectConnection={direct}
onRemoteSsh={ssh}
onPasteLink={link}
onScanQr={scan}
/>
<AddHostModal
visible={method === "direct"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<AddRemoteSshHostModal
visible={method === "ssh"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<PairLinkModal
visible={method === "link"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
{method === "scan" ? (
<AdaptiveModalSheet
header={header}
visible
onClose={session.close}
scrollable={false}
snapPoints={SCAN_SNAP_POINTS}
desktopHeight="85%"
>
<PairScanContent onConnected={scanned} onClose={back} />
</AdaptiveModalSheet>
) : null}
</>
);
}
const SCAN_SNAP_POINTS = ["90%"];
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { createAddHostFlow } from "./model";
describe("add host flow", () => {
it("closes before notifying the consumer and ignores duplicate completion", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({
onAdded: (serverId) => {
expect(flow.store.getState().session).toBeNull();
added.push(serverId);
},
});
const session = flow.store.getState().session!;
session.choose("direct");
session.complete({ serverId: "new-host" });
session.complete({ serverId: "new-host" });
expect(added).toEqual(["new-host"]);
});
it("cancels without a success action and accepts a fresh request", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({ onAdded: (id) => added.push(id) });
const cancelled = flow.store.getState().session!;
cancelled.choose("scan");
cancelled.close();
expect(flow.store.getState().session).toBeNull();
flow.open({ method: "link" });
const current = flow.store.getState().session!;
cancelled.complete({ serverId: "late-result" });
cancelled.close();
expect(flow.store.getState().session).toBe(current);
expect(current.step.getState()).toEqual({ method: "link" });
expect(added).toEqual([]);
current.complete({ serverId: "new-host" });
expect(flow.store.getState().session).toBeNull();
});
it("returns to method selection within the same request", () => {
const flow = createAddHostFlow();
flow.open();
const session = flow.store.getState().session!;
session.choose("ssh");
session.choose(null);
expect(flow.store.getState().session).toBe(session);
expect(session.step.getState()).toEqual({ method: null });
});
});
@@ -1,47 +0,0 @@
import { createStore, type StoreApi } from "zustand/vanilla";
export type AddHostMethod = "direct" | "ssh" | "link" | "scan";
export interface AddHostOptions {
method?: AddHostMethod;
onAdded?: (serverId: string) => void;
}
export interface AddHostSession {
id: number;
step: StoreApi<{ method: AddHostMethod | null }>;
choose(method: AddHostMethod | null): void;
close(): void;
complete(result: { serverId: string }): void;
}
export function createAddHostFlow() {
const store = createStore<{ session: AddHostSession | null }>(() => ({ session: null }));
let nextId = 0;
function createSession(options: AddHostOptions) {
const step = createStore<{ method: AddHostMethod | null }>(() => ({
method: options.method ?? null,
}));
const session = {
id: ++nextId,
step,
choose(method: AddHostMethod | null) {
step.setState({ method });
},
close() {
if (store.getState().session === session) store.setState({ session: null });
},
complete({ serverId }: { serverId: string }) {
if (store.getState().session !== session) return;
session.close();
options.onAdded?.(serverId);
},
};
return session;
}
return {
store,
open(options: AddHostOptions = {}) {
store.setState({ session: createSession(options) });
},
};
}
+11 -4
View File
@@ -1,11 +1,12 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { router } from "expo-router";
import { Server } from "lucide-react-native";
import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -19,6 +20,7 @@ import {
} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
@@ -63,7 +65,6 @@ function matchesHostQuery(host: HostProfile, query: string): boolean {
}
export function useHostChooser() {
const addHost = useAddHost();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open);
@@ -75,7 +76,13 @@ export function useHostChooser() {
);
if (availableHosts.length === 0) {
(input.onNoHosts ?? addHost)();
(
input.onNoHosts ??
(() => {
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
})
)();
return false;
}
@@ -91,7 +98,7 @@ export function useHostChooser() {
});
return true;
},
[hosts, localServerId, open, addHost],
[hosts, localServerId, open],
);
}
@@ -14,45 +14,6 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
}
describe("plugin timeline projection", () => {
it.each(["message", "finished", "errored", "permission-required", "closed"] as const)(
"preserves immutable agent-message provenance for %s cards",
(event) => {
const agentMessage = {
event,
sender: { id: "remote::reviewer", title: "QA question-timing #7014/#7000/#6809" },
text: "The original message, including **Markdown**.",
};
const source: StreamItem = {
kind: "tool_call",
id: "message-1",
timestamp: new Date("2026-01-01T00:00:00.000Z"),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "delivery-1",
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: agentMessage.text, icon: "bot" },
agentMessage,
},
},
};
const inspect: TimelineItemTransform = ({ item }) => {
expect(item.type).toBe("tool_call");
if (item.type !== "tool_call") throw new Error("Expected a tool call");
expect(item.agentMessage).toEqual(agentMessage);
expect(item.agentMessage).not.toBe(agentMessage);
expect(Object.isFrozen(item.agentMessage)).toBe(true);
expect(Object.isFrozen(item.agentMessage?.sender)).toBe(true);
return undefined;
};
expect(projectPluginTimelineItems([source], inspect)).toEqual([source]);
expect(Object.isFrozen(agentMessage.sender)).toBe(false);
},
);
it("does not expose mutable tool detail from stream state", () => {
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
const source: StreamItem = {
@@ -22,14 +22,13 @@ function cloneAndFreeze<T>(value: T): T {
}
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
const { callId, name, status, error, detail, metadata, agentMessage } = data;
const { callId, name, status, error, detail, metadata } = data;
const base = {
type: "tool_call" as const,
callId,
name,
detail,
...(metadata ? { metadata } : {}),
...(agentMessage ? { agentMessage } : {}),
};
switch (status) {
case "running":
@@ -1,4 +1,3 @@
import { useAddHost } from "@/hosts/add-host";
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
@@ -9,6 +8,7 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { router } from "expo-router";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
@@ -62,6 +62,7 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { toErrorMessage } from "@/utils/error-messages";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
import {
getHostProjectId,
getWorktreeSupportForHostProject,
@@ -1165,7 +1166,9 @@ export function NewWorkspaceScreen({
openAddProjectPicker(selectedServerId);
}, [openAddProjectPicker, selectedServerId]);
const handleAddHost = useAddHost();
const handleAddHost = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const openPicker = useCallback(() => {
setPickerOpen(true);
+97 -8
View File
@@ -1,5 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
@@ -75,6 +74,10 @@ import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window
import { confirmDialog } from "@/utils/confirm-dialog";
import { BackHeader } from "@/components/headers/back-header";
import { PageLayout } from "@/components/page-layout";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section";
import { FilesSection } from "@/screens/settings/files-section";
@@ -1086,15 +1089,21 @@ function SettingsSidebar({
export interface SettingsScreenProps {
view: SettingsView;
openAddHostIntent?: string | null;
}
export default function SettingsScreen({ view }: SettingsScreenProps) {
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
const router = useRouter();
const { t } = useTranslation();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isRemoteSshVisible, setIsRemoteSshVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1155,10 +1164,50 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
}
}, [isPlaybackTestRunning, t, voiceAudioEngine]);
const closeAddConnectionFlow = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
setIsAddHostMethodVisible(true);
}, []);
const handleAddHost = useCallback(() => {
setIsAddHostMethodVisible(true);
}, []);
useEffect(() => {
if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) {
return;
}
lastOpenedAddHostIntentRef.current = openAddHostIntent;
handleAddHost();
}, [handleAddHost, openAddHostIntent]);
const handleSelectDirectConnection = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
}, []);
const handleSelectRemoteSsh = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsRemoteSshVisible(true);
}, []);
const handleSelectPasteLink = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
}, []);
const handleHostAdded = useCallback(
(serverId: string) => {
selectSettingsHost(serverId);
const target = buildSettingsHostSectionRoute(serverId, "host");
({ serverId }: { serverId: string }) => {
const target = buildSettingsHostSectionRoute(serverId, "connections");
if (isCompactLayout) {
router.push(target);
} else {
@@ -1168,8 +1217,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[isCompactLayout, router],
);
const handleAddHost = useAddHost({ onAdded: handleHostAdded });
const handleSelectSection = useCallback(
(section: SettingsSectionSlug) => {
const target = buildSettingsSectionRoute(section);
@@ -1225,6 +1272,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[activeHostServerId, handleAddHost, isCompactLayout, router],
);
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: { source: "settings" },
});
}, [closeAddConnectionFlow, router]);
const handleHostRemoved = useCallback(() => {
const fallback = buildSettingsSectionRoute("general");
if (isCompactLayout) {
@@ -1333,6 +1388,37 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
);
}
const addHostModals = (
<>
<AddHostMethodModal
visible={isAddHostMethodVisible}
onClose={closeAddConnectionFlow}
onDirectConnection={handleSelectDirectConnection}
onRemoteSsh={handleSelectRemoteSsh}
onPasteLink={handleSelectPasteLink}
onScanQr={handleScanQr}
/>
<AddHostModal
visible={isDirectHostVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<AddRemoteSshHostModal
visible={isRemoteSshVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<PairLinkModal
visible={isPasteLinkVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
</>
);
// Mobile root: full-screen sidebar-as-list.
if (isCompactLayout && view.kind === "root") {
return (
@@ -1350,6 +1436,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
layout="mobile"
/>
</ScrollView>
{addHostModals}
</View>
);
}
@@ -1360,6 +1447,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
{content}
</PageLayout>
{addHostModals}
</View>
);
}
@@ -1394,6 +1482,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
</View>
</WindowChromeRegion>
</View>
{addHostModals}
</View>
);
}
@@ -8,6 +8,7 @@ import {
buildOpenProjectRoute,
resolveKnownHostRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildProjectSettingsRoute,
buildProjectsSettingsRoute,
decodeFilePathFromPathSegment,
@@ -184,6 +185,14 @@ describe("workspace route parsing", () => {
});
describe("projects settings routes", () => {
it("buildSettingsAddHostRoute opens settings with the add-host flag", () => {
expect(buildSettingsAddHostRoute()).toBe("/settings/general?addHost=1");
});
it("buildSettingsAddHostRoute accepts a repeatable intent id", () => {
expect(buildSettingsAddHostRoute("retry 1")).toBe("/settings/general?addHost=retry%201");
});
it("buildProjectsSettingsRoute scopes the list to a host", () => {
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
});
+4
View File
@@ -553,6 +553,10 @@ export function buildSettingsSectionRoute(section: SettingsSectionSlug) {
return `/settings/${section}` as const;
}
export function buildSettingsAddHostRoute(intentId: string | number = "1") {
return `/settings/general?addHost=${encodeURIComponent(String(intentId))}` as const;
}
export function buildSettingsHostRoute(serverId: string) {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
-16
View File
@@ -12,7 +12,6 @@ import { fileURLToPath } from "node:url";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { chromium } from "playwright";
import { expect } from "@playwright/test";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
@@ -199,20 +198,6 @@ async function waitForDesktopStatus(page) {
);
}
async function settleDesktopStartup(page) {
await expect(
page.getByRole("button", { name: "Desktop browser workspace 1", exact: true }),
).toBeVisible({ timeout: timeoutMs });
// The dev launcher supplies its package directory as an open-project request.
// Before the local host connects, that request opens the connection prompt.
// Finish startup before exercising the unrelated Settings/browser workflows.
const connectionPrompt = page.getByTestId("add-host-method-modal");
if (await connectionPrompt.isVisible()) {
await connectionPrompt.getByRole("button", { name: "Close", exact: true }).click();
await expect(connectionPrompt).toBeHidden();
}
}
async function startTargetPage() {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -1223,7 +1208,6 @@ async function main() {
const page = await waitForAppPage(browser, expoPort);
await hideDeveloperToasts(page);
const status = await waitForDesktopStatus(page);
await settleDesktopStartup(page);
const checkPluginLinks = () =>
runPluginLinksRegression({
@@ -13,6 +13,11 @@ interface ScriptedTurn {
before?: string;
after?: string;
isError: boolean;
subtype?:
| "error_max_turns"
| "error_during_execution"
| "error_max_budget_usd"
| "error_max_structured_output_retries";
usage?: {
inputTokens: number;
cachedInputTokens: number;
@@ -57,11 +62,12 @@ function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
if (turn.after) yield assistant(`after-${index}`, turn.after);
yield {
type: "result",
subtype: "success",
subtype: turn.subtype ?? "success",
session_id: "error-session",
duration_ms: 1,
duration_api_ms: 0,
is_error: turn.isError,
...(turn.subtype ? { errors: ["Reached maximum number of turns (1)"] } : {}),
num_turns: 1,
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
stop_reason: "stop_sequence",
@@ -188,3 +194,33 @@ test("an error reply the turn moves past is still shown", async () => {
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(completed)).toEqual([LIMIT]);
});
test.each([
"error_max_turns",
"error_during_execution",
"error_max_budget_usd",
"error_max_structured_output_retries",
] as const)("a %s result publishes final usage before failure", async (subtype) => {
const usage = {
inputTokens: 2,
cachedInputTokens: 12577,
outputTokens: 110,
totalCostUsd: 0.00365537,
};
const [failed, next] = await runTurns([
{ before: "Running the command.", isError: true, subtype, usage },
{ after: "Done.", isError: false },
]);
// Collection ends at the terminal event, so this also checks ordering.
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({
type: "turn_failed",
error: "Reached maximum number of turns (1)",
});
expect(assistantTexts(failed)).toEqual(["Running the command."]);
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
@@ -5037,6 +5037,7 @@ class ClaudeAgentSession implements AgentSession {
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
? message.errors.join("\n")
: "Claude run failed";
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(errorMessage));
}
+1 -40
View File
@@ -6,7 +6,7 @@ const desktopName =
async function openHomepage(page: Page) {
await page.setViewportSize({ width: 1512, height: 930 });
await page.goto("/");
await expect(page.getByRole("button", { name: "Parallel agents", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible();
}
async function viewPhones(page: Page) {
@@ -59,42 +59,3 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page })
await resizeToPhone(page);
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
});
/** Opacity as rendered: the element's own times every ancestor's. */
async function renderedOpacity(locator: Locator) {
return locator.evaluate((element) => {
let opacity = 1;
for (let node: Element | null = element; node; node = node.parentElement) {
opacity *= Number.parseFloat(getComputedStyle(node).opacity);
}
return opacity;
});
}
test("the plugin demo plays through to the panel the agent built", async ({ page }) => {
await openHomepage(page);
const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true });
expect(await renderedOpacity(builtPanelRow)).toBe(0);
// Hydration can reset the scroll position, so keep the demo in view until it finishes.
const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" });
await expect(async () => {
await heading.scrollIntoViewIfNeeded();
expect(await renderedOpacity(builtPanelRow)).toBe(1);
}).toPass({ timeout: 15_000 });
expect(await renderedOpacity(page.getByText("Review queue is in your sidebar."))).toBe(1);
});
test("the plugin demo shows its finished state when motion is reduced", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openHomepage(page);
const builtPanelRow = page.getByText("Add Gruvbox theme", { exact: true });
const panel = builtPanelRow.locator("xpath=ancestor::div[contains(@class, 'md:border-l')]");
const heading = page.getByRole("heading", { name: "Ask Paseo to change Paseo" });
await expect(async () => {
await heading.scrollIntoViewIfNeeded();
expect(await renderedOpacity(builtPanelRow)).toBe(1);
}).toPass({ timeout: 2_000 });
expect(await panel.evaluate((element) => getComputedStyle(element).transform)).toBe("none");
});
+47 -209
View File
@@ -1,6 +1,4 @@
import { PLUGINS_LINKED } from "~/plugins/launch";
import { categoryHref } from "~/plugins/links";
import type { CategorySlug } from "~/plugins/categories";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import {
@@ -12,7 +10,7 @@ import {
ExternalLink,
Gem,
GitFork,
GitPullRequest,
LayoutPanelLeft,
Laptop,
Lock,
Merge,
@@ -21,17 +19,16 @@ import {
Smartphone,
Terminal,
Users,
Workflow,
type LucideIcon,
} from "lucide-react";
import {
motion,
AnimatePresence,
useInView,
useReducedMotion,
useScroll,
useTransform,
type Transition,
type Variants,
} from "framer-motion";
// Shared motion presets — hoisted so every JSX site receives the same object
@@ -89,14 +86,7 @@ import {
PiIcon,
} from "~/components/agent-icons";
import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
import {
MobileChat,
MobileDiff,
MobileSidebar,
PhoneFrame,
type ToolCall,
ToolCallRow,
} from "~/components/mockup";
import { MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup";
import { FAQItem } from "~/components/faq-item";
import { SiteFooter } from "~/components/site-footer";
import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
@@ -142,8 +132,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<SocialProofWall />
<MultiProviderSection />
<TurnkeySection />
<ExtensibleSection />
<AutomationSection />
<ExtensibleSection />
<SponsorsSection placement="page" />
<SponsorSection />
<PhilosophySection />
@@ -713,50 +703,41 @@ function AutomationDetail({ kind }: { kind: AutomationKind }) {
);
}
const MCP_AGENT_CALLS: ToolCall[] = [
{ kind: "paseo", label: "Create agent", detail: "Fix #412: retry flaky relay handshake" },
{ kind: "paseo", label: "Create agent", detail: "Fix #417: group schedules by project" },
];
function McpAutomationTranscript() {
const { t } = useTranslation();
return (
<AgentTurn request={t("landing.automation.transcriptRequest")}>
<AgentText>{t("landing.automation.transcriptPlan")}</AgentText>
<ToolCalls calls={MCP_AGENT_CALLS} />
<AgentText>{t("landing.automation.transcriptDone")}</AgentText>
</AgentTurn>
);
}
/** A user message and the agent's reply, laid out like the app's timeline. */
function AgentTurn({ request, children }: { request: string; children: React.ReactNode }) {
return (
<div className="space-y-5">
<UserMessage>{request}</UserMessage>
<div className="space-y-3">{children}</div>
<div className="ml-auto w-fit max-w-xl rounded-xl rounded-tr-none bg-white/[0.07] px-4 py-3">
<p className="text-sm leading-relaxed text-white/75">
{t("landing.automation.transcriptRequest")}
</p>
</div>
<div className="flex items-start gap-3">
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.5} />
<div className="min-w-0 flex-1 space-y-4">
<p className="text-sm leading-relaxed text-white/55">
{t("landing.automation.transcriptPlan")}
</p>
<div className="space-y-2 font-mono text-[11px]">
<McpAgentCall issue="#412" provider="claude/opus-4.6" />
<McpAgentCall issue="#417" provider="codex/gpt-5.6-sol" />
</div>
<p className="text-sm leading-relaxed text-white/55">
{t("landing.automation.transcriptDone")}
</p>
</div>
</div>
</div>
);
}
function UserMessage({ children }: { children: React.ReactNode }) {
function McpAgentCall({ issue, provider }: { issue: string; provider: string }) {
return (
<div className="ml-auto w-fit max-w-md rounded-xl bg-white/[0.07] px-4 py-3">
<p className="text-sm leading-relaxed text-white/80">{children}</p>
</div>
);
}
function AgentText({ children }: { children: React.ReactNode }) {
return <p className="text-sm leading-relaxed text-white/80">{children}</p>;
}
function ToolCalls({ calls }: { calls: ReadonlyArray<ToolCall> }) {
return (
<div>
{calls.map((call) => (
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
))}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-white/[0.07] bg-white/[0.025] px-3 py-2">
<span className="text-sky-300/80">create_agent</span>
<span className="text-white/25">{issue}</span>
<span className="text-white/35">{provider}</span>
<span className="text-white/25">worktree</span>
</div>
);
}
@@ -861,16 +842,17 @@ function ExtensibleSection() {
title={t("landing.plugins.title")}
description={t("landing.plugins.description")}
>
<div className="space-y-12">
<SelfModifyDemo />
<div className="space-y-6">
<ProductLead
icon={Puzzle}
title={t("landing.plugins.libraryTitle")}
description={t("landing.plugins.libraryDescription")}
actions={pluginActions}
/>
<PluginCategoryLinks />
<div className="space-y-8">
<ProductLead icon={Puzzle} title={t("landing.plugins.lead")} actions={pluginActions} />
<div className="grid gap-4 md:grid-cols-3">
{PLUGIN_CAPABILITIES.map((capability) => (
<FeatureCard
key={capability.key}
icon={capability.icon}
title={t(`landing.plugins.${capability.key}Title`)}
description={t(`landing.plugins.${capability.key}Description`)}
/>
))}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/45">{t("landing.plugins.license")}</p>
@@ -890,156 +872,6 @@ function ExtensibleSection() {
);
}
const PLUGIN_BUILD_CALLS: ToolCall[] = [
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
{ kind: "shell", detail: "paseo plugin init review-queue" },
{ kind: "write", detail: "review-queue/index.ts" },
{ kind: "write", detail: "review-queue/review-queue.client.tsx" },
{ kind: "shell", detail: "paseo plugin install ./review-queue" },
];
// The demo plays once when it scrolls into view: the prompt, then each timeline
// item in turn, then the panel the agent built slides in and fills.
const DEMO_STEP_SECONDS = 0.45;
const DEMO_STEPS = {
prompt: 0,
plan: 1.4,
firstCall: 2.2,
} as const;
const DEMO_DONE_STEP = DEMO_STEPS.firstCall + PLUGIN_BUILD_CALLS.length;
const DEMO_PANEL_STEP = DEMO_DONE_STEP + 1;
const DEMO_ITEM_VARIANTS: Variants = {
hidden: { opacity: 0, y: 6 },
shown: (step: number) => ({
opacity: 1,
y: 0,
transition: { delay: step * DEMO_STEP_SECONDS, duration: 0.35, ease: "easeOut" },
}),
};
const DEMO_PANEL_VARIANTS: Variants = {
hidden: { opacity: 0, x: "100%" },
shown: {
opacity: 1,
x: 0,
transition: {
delay: DEMO_PANEL_STEP * DEMO_STEP_SECONDS,
duration: 0.6,
ease: [0.22, 0.61, 0.36, 1],
},
},
};
/** One piece of the demo that fades in at its step. */
function DemoItem({ step, children }: { step: number; children: React.ReactNode }) {
return (
<motion.div variants={DEMO_ITEM_VARIANTS} custom={step}>
{children}
</motion.div>
);
}
/** The ask on the left, the panel it produced on the right. */
function SelfModifyDemo() {
const { t } = useTranslation();
// Visitors who ask for reduced motion get the finished demo, with no slide or staging.
const reduceMotion = useReducedMotion();
return (
<motion.div
initial={reduceMotion ? "shown" : "hidden"}
whileInView="shown"
viewport={VIEWPORT_60}
className="grid min-h-[22rem] grid-cols-[minmax(0,1fr)] overflow-hidden rounded-xl border border-white/10 bg-black/20 md:grid-cols-[minmax(0,1fr)_17rem]"
>
<div className="space-y-5 p-5 md:p-6">
<DemoItem step={DEMO_STEPS.prompt}>
<UserMessage>{t("landing.plugins.request")}</UserMessage>
</DemoItem>
<div className="space-y-3">
<DemoItem step={DEMO_STEPS.plan}>
<AgentText>{t("landing.plugins.plan")}</AgentText>
</DemoItem>
<div>
{PLUGIN_BUILD_CALLS.map((call, index) => (
<DemoItem key={`${call.kind}:${call.detail}`} step={DEMO_STEPS.firstCall + index}>
<ToolCallRow call={call} />
</DemoItem>
))}
</div>
<DemoItem step={DEMO_DONE_STEP}>
<AgentText>{t("landing.plugins.done")}</AgentText>
</DemoItem>
</div>
</div>
<ReviewQueuePreview />
</motion.div>
);
}
const REVIEW_QUEUE = [
{ number: "#418", title: "Retry flaky relay handshake", author: "ana", age: "12m" },
{ number: "#415", title: "Group schedules by project", author: "kenji", age: "1h" },
{ number: "#409", title: "Add Gruvbox theme", author: "lea", age: "3h" },
] as const;
/** The panel the agent just built, docked like a real sidebar panel. */
function ReviewQueuePreview() {
const { t } = useTranslation();
return (
<motion.div
variants={DEMO_PANEL_VARIANTS}
className="border-t border-white/10 bg-white/[0.025] p-4 md:border-t-0 md:border-l"
>
<div className="mb-3 flex items-center gap-2">
<GitPullRequest className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
<span className="text-sm font-medium text-white/85">{t("landing.plugins.panelTitle")}</span>
<span className="ml-auto rounded-full bg-white/[0.07] px-2 py-0.5 text-[10px] text-white/55">
{REVIEW_QUEUE.length}
</span>
</div>
<div className="space-y-1">
{REVIEW_QUEUE.map((pr, index) => (
<DemoItem key={pr.number} step={DEMO_PANEL_STEP + 1.2 + index * 0.4}>
<div className="rounded-md px-2 py-2 hover:bg-white/[0.04]">
<p className="truncate text-xs text-white/80">{pr.title}</p>
<p className="mt-0.5 font-mono text-[10px] text-white/35">
{pr.number} · {pr.author} · {pr.age}
</p>
</div>
</DemoItem>
))}
</div>
</motion.div>
);
}
const SHOWCASE_CATEGORIES = [
"themes",
"sidebar",
"workspaces",
"providers",
"orchestration",
"git",
] as const satisfies ReadonlyArray<CategorySlug>;
function PluginCategoryLinks() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<div className="flex flex-wrap gap-2">
{SHOWCASE_CATEGORIES.map((category) => (
<a
key={category}
href={localize(categoryHref(category))}
className="rounded-full border border-white/10 bg-white/[0.025] px-3 py-1.5 text-xs text-white/70 transition-colors hover:border-white/15 hover:bg-white/[0.06] hover:text-white"
>
{t(`plugins.categories.${category}.label`)}
</a>
))}
</div>
);
}
const PLUGINS_BROWSE = PLUGINS_LINKED
? { href: "/plugins", external: false }
: { href: "https://paseo.cafe", external: true };
@@ -1067,6 +899,12 @@ function PluginActions() {
);
}
const PLUGIN_CAPABILITIES = [
{ icon: LayoutPanelLeft, key: "ui" },
{ icon: Workflow, key: "lifecycle" },
{ icon: Bot, key: "providers" },
] as const;
const PRINCIPLES = [
{ icon: Lock, key: "private" },
{ icon: Compass, key: "independent" },
+47 -39
View File
@@ -1,14 +1,14 @@
import {
AudioLines,
BookOpen,
Brain,
ChevronDown,
CircleDot,
Copy,
Eye,
FilePenLine,
LoaderCircle,
ListTodo,
Mic,
Pencil,
Plus,
ShieldOff,
Split,
@@ -18,7 +18,7 @@ import {
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { DiffStat } from "./atoms";
import { CodexIcon, PaseoMark } from "./icons";
import { CodexIcon } from "./icons";
// Icons passed as props are hoisted so they are not rebuilt on every render.
const MODEL_ICON = <CodexIcon size={13} />;
@@ -94,62 +94,70 @@ export function AgentTranscript() {
);
}
/**
* A collapsed tool call as the app's timeline shows it. Built-in tools carry a
* fixed label; Paseo MCP tools name their action, such as "Create agent".
*/
export type ToolCall =
| { kind: "read" | "write" | "shell"; detail: string }
| { kind: "paseo"; label: string; detail: string };
const BUILT_IN_LABELS = { read: "Read", write: "Write", shell: "Shell" } as const;
function toolCallLabel(call: ToolCall): string {
return call.kind === "paseo" ? call.label : BUILT_IN_LABELS[call.kind];
interface PluginToolCall {
kind: "read" | "shell" | "write";
label: string;
detail: string;
}
const PLUGIN_TOOL_CALLS: ToolCall[] = [
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
{ kind: "shell", detail: "paseo plugin init ~/dev/release-radar" },
{ kind: "write", detail: "~/dev/release-radar/index.ts" },
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
{ kind: "shell", detail: "npm install && npm run typecheck" },
{ kind: "shell", detail: "paseo plugin install ~/dev/release-radar" },
const PLUGIN_TOOL_CALLS: PluginToolCall[] = [
{ kind: "read", label: "Read", detail: "paseo-plugin/SKILL.md" },
{
kind: "shell",
label: "Shell",
detail: "paseo plugin init ~/dev/release-radar",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/index.ts",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/release-radar.client.tsx",
},
{
kind: "shell",
label: "Shell",
detail: "npm install && npm run typecheck",
},
{
kind: "shell",
label: "Shell",
detail: "paseo plugin install ~/dev/release-radar",
},
];
const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) {
if (kind === "read") return <Eye size={14} />;
if (kind === "write") return <Pencil size={14} />;
if (kind === "paseo") return <PaseoMark size={14} />;
function PluginToolIcon({ kind }: { kind: PluginToolCall["kind"] }) {
if (kind === "read") return <BookOpen size={14} />;
if (kind === "write") return <FilePenLine size={14} />;
return <SquareTerminal size={14} />;
}
/**
* One tool call row, drawn like the app's collapsed timeline item
* (`packages/app/src/components/message.tsx`): icon, label, then the summary,
* all muted and in the UI font.
*/
export function ToolCallRow({ call }: { call: ToolCall }) {
function PluginToolRow({ call }: { call: PluginToolCall }) {
return (
<div className="flex items-center gap-[8px] py-[5px] text-[12.5px] leading-[18px] text-mock-fg-muted">
<span className="shrink-0">
<ToolCallIcon kind={call.kind} />
<div className="flex items-center gap-[9px] py-[5px] text-[12.5px] leading-[18px]">
<span className="text-mock-fg-muted">
<PluginToolIcon kind={call.kind} />
</span>
<span className="shrink-0 text-mock-fg">{call.label}</span>
<span className="min-w-0 truncate font-mono text-[11.5px] text-mock-fg-muted">
{call.detail}
</span>
<span className="shrink-0">{toolCallLabel(call)}</span>
<span className="min-w-0 truncate">{call.detail}</span>
</div>
);
}
function PluginToolGroup({ calls }: { calls: ToolCall[] }) {
function PluginToolGroup({ calls }: { calls: PluginToolCall[] }) {
return (
<div>
{calls.map((call) => (
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
<PluginToolRow key={`${call.label}:${call.detail}`} call={call} />
))}
</div>
);
@@ -1,5 +1,4 @@
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
export { type ToolCall, ToolCallRow } from "./chat";
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
export { PhoneFrame } from "./mobile/frame";
export { MobileChat } from "./mobile/chat";
+14 -13
View File
@@ -375,18 +375,19 @@
"otherHostComment": "# Target another host"
},
"plugins": {
"title": "Ask Paseo to change Paseo",
"description": "Agents in Paseo can change the whole UI without a restart, even on mobile.",
"request": "Add a sidebar panel with the PRs waiting on my review.",
"plan": "I'll build it as a local plugin and install it.",
"done": "Review queue is in your sidebar.",
"panelTitle": "Review queue",
"libraryTitle": "400+ community plugins",
"libraryDescription": "Built by developers on the same plugin API your agents use",
"title": "Extend it with plugins",
"description": "Add new panels and functionality that work in desktop, web and mobile",
"lead": "Plugins",
"browse": "Browse plugins",
"docs": "Plugin docs",
"license": "Paseo is Apache 2.0.",
"fork": "Fork the repo"
"fork": "Fork the repo",
"uiTitle": "UI",
"uiDescription": "Screens, workspace panels, sidebar items, slash commands, and themes",
"lifecycleTitle": "Agent lifecycle",
"lifecycleDescription": "Change config and MCP servers, answer permissions, and follow up",
"providersTitle": "Providers",
"providersDescription": "Add a coding agent as a provider, used like the built-in ones"
},
"philosophy": {
"title": "Philosophy",
@@ -446,10 +447,10 @@
},
"mockup": {
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
"build": "Parallel agents",
"review": "Code review",
"ship": "Pull requests",
"extend": "Plugins",
"build": "Build",
"review": "Review",
"ship": "Ship",
"extend": "Extend",
"app": {
"newWorkspace": "New workspace",
"history": "History",
+14 -13
View File
@@ -375,18 +375,19 @@
"otherHostComment": "# 指定另一台主机"
},
"plugins": {
"title": "让 Paseo 修改 Paseo",
"description": "Paseo 里的 Agent 可以修改整个界面,无需重启,在移动端也一样。",
"request": "在侧边栏加一个面板,列出等待我审查的 PR。",
"plan": "我会把它做成本地插件并安装。",
"done": "审查队列已出现在你的侧边栏。",
"panelTitle": "审查队列",
"libraryTitle": "400 多个社区插件",
"libraryDescription": "开发者用的是和你的 Agent 相同的插件 API",
"title": "用插件扩展",
"description": "添加新的面板和功能,在桌面端、网页端和移动端都能使用",
"lead": "插件",
"browse": "浏览插件",
"docs": "插件文档",
"license": "Paseo 采用 Apache 2.0 许可证。",
"fork": "Fork 仓库"
"fork": "Fork 仓库",
"uiTitle": "界面",
"uiDescription": "页面、工作区面板、侧边栏项目、斜杠命令和主题",
"lifecycleTitle": "Agent 生命周期",
"lifecycleDescription": "修改配置和 MCP 服务器、响应权限请求、发送后续消息",
"providersTitle": "Provider",
"providersDescription": "把编程 Agent 添加为 Provider,用法与内置 Provider 相同"
},
"philosophy": {
"title": "理念",
@@ -446,10 +447,10 @@
},
"mockup": {
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
"build": "并行 Agent",
"review": "代码审查",
"ship": "Pull Request",
"extend": "插件",
"build": "构建",
"review": "审查",
"ship": "发布",
"extend": "扩展",
"app": {
"newWorkspace": "新建工作区",
"history": "历史",