mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1211cceaa1 | ||
|
|
cacd97d663 | ||
|
|
da8d58bfee | ||
|
|
7c2812dde6 | ||
|
|
b474beedce | ||
|
|
e2f0779379 | ||
|
|
a166a3317a | ||
|
|
efc64a5443 | ||
|
|
7efa14238d | ||
|
|
dbbcb103ba | ||
|
|
7f318bf7cf |
@@ -406,10 +406,7 @@
|
||||
{
|
||||
"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",
|
||||
@@ -428,7 +425,6 @@
|
||||
"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",
|
||||
@@ -543,14 +539,10 @@
|
||||
"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
@@ -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/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`).
|
||||
- **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`).
|
||||
- **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/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>` 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>` 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/components/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/hosts/add-host/internal/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/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.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` |
|
||||
| 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` |
|
||||
|
||||
@@ -58,6 +58,12 @@ 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,6 +5,7 @@ import path from "node:path";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import {
|
||||
addProjectFlow,
|
||||
chooseAddHostFromProjectFlow,
|
||||
addProjectFlowBack,
|
||||
addProjectFlowHost,
|
||||
addProjectFlowInput,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "../support/helpers/add-project-flow";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
disconnectAllHostsAndReload,
|
||||
addConnectedHostAndReload,
|
||||
addConnectedHostsAndReload,
|
||||
addOfflineHostAndReload,
|
||||
@@ -69,6 +71,19 @@ 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,
|
||||
}) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
@@ -16,6 +17,7 @@ interface DirtyWorkspace {
|
||||
}
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
workspaceSubdirectory?: string;
|
||||
includeDeletedFile?: boolean;
|
||||
includeNestedFolders?: boolean;
|
||||
includeRenamedFile?: boolean;
|
||||
@@ -435,6 +437,114 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
for (const { workspaceSubdirectory, filePath, content, visibleContent } of [
|
||||
{
|
||||
workspaceSubdirectory: ".",
|
||||
filePath: "src/use-mounted-tab-set.ts",
|
||||
content: AFTER,
|
||||
visibleContent: "interface DeriveRenderMountedTabIdsInput",
|
||||
},
|
||||
{
|
||||
workspaceSubdirectory: "src",
|
||||
filePath: "src/use-mounted-tab-set.ts",
|
||||
content: AFTER,
|
||||
visibleContent: "interface DeriveRenderMountedTabIdsInput",
|
||||
},
|
||||
{
|
||||
workspaceSubdirectory: "src",
|
||||
filePath: "a-root-note.txt",
|
||||
content: "root note\n",
|
||||
visibleContent: "root note",
|
||||
},
|
||||
]) {
|
||||
test(`changes file actions resolve ${filePath} from project ${workspaceSubdirectory}`, async ({
|
||||
page,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({
|
||||
workspaceSubdirectory,
|
||||
includeRootFileSortingFirst: filePath === "a-root-note.txt",
|
||||
});
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const header = diffHeaderForPath(panel, filePath).getByTestId(/^diff-file-\d+-toggle$/);
|
||||
await header.click({ button: "right" });
|
||||
await testInfo.attach("changes-file-menu", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await test.step("Duplicate creates a copy next to the source", async () => {
|
||||
const file = path.parse(filePath);
|
||||
const copyPath = path.join(workspace.repoPath, file.dir, `${file.name} copy${file.ext}`);
|
||||
await page.getByRole("menuitem", { name: "Duplicate", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => readFileIfPresent(copyPath), { message: `Expected duplicate at ${copyPath}` })
|
||||
.toBe(content);
|
||||
});
|
||||
await header.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Copy relative path", exact: true }).click();
|
||||
await expectClipboardText(page, filePath);
|
||||
await header.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Copy path", exact: true }).click();
|
||||
await expectClipboardText(page, path.join(workspace.repoPath, filePath));
|
||||
|
||||
await header.click({ button: "right" });
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
const download = await downloaded;
|
||||
expect(download.suggestedFilename()).toBe(path.basename(filePath));
|
||||
expect(await readFile((await download.path())!, "utf8")).toBe(content);
|
||||
|
||||
await header.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open file", exact: true }).click();
|
||||
const editor = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(editor.locator(".cm-content")).toContainText(visibleContent);
|
||||
await testInfo.attach("opened-changed-file", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Changes and Files reuse a retained file tab in a subfolder project", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ workspaceSubdirectory: "src" });
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await openChangedFile(page, "src/use-mounted-tab-set.ts");
|
||||
await editAndSaveChangedFile(page, workspace);
|
||||
await openChangesPanel(page);
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "use-mounted-tab-set.ts");
|
||||
|
||||
await expect(page.getByTestId(/^workspace-tab-file_/).filter({ visible: true })).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-tab-file_use-mounted-tab-set.ts")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(visibleFileEditor(page)).toContainText("// retained file tab");
|
||||
});
|
||||
|
||||
async function openChangedFile(page: Page, filePath: string): Promise<void> {
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await diffHeaderForPath(panel, filePath)
|
||||
.getByTestId(/^diff-file-\d+-toggle$/)
|
||||
.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open file", exact: true }).click();
|
||||
await expect(visibleFileEditor(page)).toContainText("interface DeriveRenderMountedTabIdsInput");
|
||||
}
|
||||
|
||||
function visibleFileEditor(page: Page): Locator {
|
||||
return page.getByTestId("workspace-file-pane").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
|
||||
async function editAndSaveChangedFile(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
const content = "// retained file tab\n" + AFTER;
|
||||
await visibleFileEditor(page).fill(content);
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(content);
|
||||
}
|
||||
|
||||
test("canvas file headers select without toggling for context menu and long press", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1591,7 +1701,7 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
);
|
||||
}
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
source: { kind: "directory", path: path.join(repo.path, options.workspaceSubdirectory ?? ".") },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||
|
||||
@@ -294,10 +294,17 @@ 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$/);
|
||||
const deleteLabelColor = await deleteAction
|
||||
.getByText("Delete", { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).color);
|
||||
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
|
||||
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 cancelledConfirmation = new Promise<string>((resolve) => {
|
||||
page.once("dialog", async (dialog) => {
|
||||
const message = dialog.message();
|
||||
|
||||
@@ -122,10 +122,14 @@ 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).toHaveURL(/\/settings\/general\?addHost=/);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Direct connection", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(originalUrl);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
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";
|
||||
@@ -273,3 +278,71 @@ 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,3 +113,7 @@ 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();
|
||||
}
|
||||
|
||||
@@ -273,3 +273,25 @@ 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();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -465,6 +466,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const surface = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<AddHostFlowHost />
|
||||
<AppearanceStyleBoundary>
|
||||
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
|
||||
<WindowChromeRegion corners="top-left">
|
||||
@@ -805,7 +807,6 @@ 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" />
|
||||
@@ -890,11 +891,13 @@ function RootAppTree() {
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
<AddHostProvider>
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</AddHostProvider>
|
||||
</LucideProvider>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -35,10 +35,9 @@ function SettingsDaemonRedirect() {
|
||||
}
|
||||
|
||||
export default function SettingsSectionRoute() {
|
||||
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
|
||||
const params = useLocalSearchParams<{ section?: 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.
|
||||
@@ -50,5 +49,5 @@ export default function SettingsSectionRoute() {
|
||||
);
|
||||
}
|
||||
|
||||
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
|
||||
return <SettingsScreen view={view} />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
@@ -92,7 +93,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, buildSettingsAddHostRoute } from "@/utils/host-routes";
|
||||
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface AddProjectFlowProps {
|
||||
request: AddProjectFlowRequest;
|
||||
@@ -325,6 +326,7 @@ 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);
|
||||
@@ -616,9 +618,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();
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
addHost();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -717,6 +719,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
directoryPaths,
|
||||
githubQuery.data,
|
||||
host,
|
||||
addHost,
|
||||
onClose,
|
||||
openAddedProject,
|
||||
page,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { router } from "expo-router";
|
||||
import {
|
||||
CircleGauge,
|
||||
@@ -62,7 +63,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 { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { 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";
|
||||
@@ -177,14 +178,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
router.push(buildSettingsRoute());
|
||||
}, []);
|
||||
|
||||
const handleAddHostMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleAddHostDesktop = useCallback(() => {
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, []);
|
||||
const handleAddHostMobile = useAddHost();
|
||||
const handleAddHostDesktop = handleAddHostMobile;
|
||||
|
||||
const handleOpenHostSettingsMobile = useCallback(
|
||||
(serverId: string) => {
|
||||
|
||||
@@ -130,6 +130,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a table directly after a paragraph through the library parser", async () => {
|
||||
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
|
||||
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
|
||||
const flags = { hardSoftBreaks: true, latexMath: false };
|
||||
const expected = await parseMarkdown(separated, flags);
|
||||
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a quote",
|
||||
"> Notes\n> | Item |\n> | --- |\n> | A |",
|
||||
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
|
||||
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||
],
|
||||
[
|
||||
"a list item",
|
||||
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
|
||||
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
|
||||
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||
],
|
||||
[
|
||||
"a quoted list item",
|
||||
"> - Notes\n> | Item |\n> | --- |\n> | A |",
|
||||
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
|
||||
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
|
||||
],
|
||||
[
|
||||
"a quote inside a list item",
|
||||
"- > Notes\n > | Item |\n > | --- |\n > | A |",
|
||||
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
|
||||
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
|
||||
],
|
||||
[
|
||||
"aligned columns without outer pipes",
|
||||
"Notes\nItem | Detail\n:--- | ---:\nA | one",
|
||||
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"an entity at the start of a header without outer pipes",
|
||||
"Notes\nAT&T | Detail\n--- | ---\nA | one",
|
||||
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"a local image at the start of a header without outer pipes",
|
||||
"Notes\n | Detail\n--- | ---\nA | one",
|
||||
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"CRLF lines",
|
||||
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"CR lines",
|
||||
"Notes\r| Item |\r| --- |\r| A |",
|
||||
"Notes\r\r| Item |\r| --- |\r| A |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"a quote with CRLF lines",
|
||||
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
|
||||
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
|
||||
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||
],
|
||||
[
|
||||
"a list item with CRLF lines",
|
||||
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
|
||||
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
|
||||
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||
],
|
||||
[
|
||||
"a quote with CR lines",
|
||||
"> Notes\r> | Item |\r> | --- |\r> | A |",
|
||||
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
|
||||
["Document", ["Blockquote", "Paragraph", "Table"]],
|
||||
],
|
||||
[
|
||||
"a list item with CR lines",
|
||||
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
|
||||
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
|
||||
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
|
||||
],
|
||||
[
|
||||
"a non-interrupting ordered marker with tildes",
|
||||
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
|
||||
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
[
|
||||
"a non-interrupting ordered marker with backticks",
|
||||
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
|
||||
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
|
||||
["Document", "Paragraph", "Table"],
|
||||
],
|
||||
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
|
||||
const rendered = prepareMarkdownSource(source).render();
|
||||
expect(rendered).toEqual(separated);
|
||||
const flags = { hardSoftBreaks: true, latexMath: false };
|
||||
function blocks(node: ASTNode): unknown {
|
||||
if (node.type === "Paragraph" || node.type === "Table") return node.type;
|
||||
return [node.type, ...(node.children ?? []).map(blocks)];
|
||||
}
|
||||
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a literal backslash",
|
||||
"Notes\\\n| Item |\n| --- |\n| A |",
|
||||
"Notes\\\n\n| Item |\n| --- |\n| A |",
|
||||
"Notes\\",
|
||||
],
|
||||
[
|
||||
"an entity and literal backslash with LF lines",
|
||||
"AT&T\\\n| Item |\n| --- |\n| A |",
|
||||
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
|
||||
"AT&T\\",
|
||||
],
|
||||
[
|
||||
"an entity and literal backslash with CRLF lines",
|
||||
"AT&T\\\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
|
||||
"AT&T\\",
|
||||
],
|
||||
[
|
||||
"an entity and literal backslash with CR lines",
|
||||
"AT&T\\\r| Item |\r| --- |\r| A |",
|
||||
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
|
||||
"AT&T\\",
|
||||
],
|
||||
])(
|
||||
"preserves paragraph text before an interrupting table in %s",
|
||||
async (_name, source, separated, text) => {
|
||||
const rendered = prepareMarkdownSource(source).render();
|
||||
expect(rendered).toEqual(separated);
|
||||
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
|
||||
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"```yaml\n---\nkey: value\n---\n```\n",
|
||||
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
|
||||
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
|
||||
"> ```md\n> | Item |\n> | --- |\n> ```\n",
|
||||
"> ```md\n> Code\n",
|
||||
"- ```md\n Code\n",
|
||||
" ```\n\n",
|
||||
"```not`a-fence\n",
|
||||
])("renders a table after a fence without changing its code: %s", async (prefix) => {
|
||||
const table = "| Item |\n| --- |\n| A |";
|
||||
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
|
||||
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
|
||||
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
|
||||
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
|
||||
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
|
||||
"Notes\n\n| Item |\n| --- |\n| A |",
|
||||
"| Item |\n| --- |\n| A |",
|
||||
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
|
||||
"```yaml\n---\nkey: value\n---\n```",
|
||||
"```yaml\n---\nkey: value",
|
||||
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
|
||||
"````md\n```\n| Item |\n| --- |\n```\n````",
|
||||
" Notes\n | Item |\n | --- |\n | A |",
|
||||
"Notes\n| Item | Detail |\n| --- |\n| A |",
|
||||
])("leaves separated tables and table-like text unchanged: %s", (source) => {
|
||||
expect(prepareMarkdownSource(source).render()).toEqual(source);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"src/components/message.tsx",
|
||||
"C:\\repo\\file.ts",
|
||||
|
||||
@@ -138,6 +138,10 @@ export function prepareMarkdownSource(
|
||||
!source.includes("![") &&
|
||||
!source.includes("||") &&
|
||||
!source.includes("&") &&
|
||||
// Match content, header, and delimiter lines; quote-only lines count as blank.
|
||||
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
|
||||
source,
|
||||
) &&
|
||||
!(workspaceRoot && source.includes("]("))
|
||||
) {
|
||||
return { images, ...metadata, render: () => source };
|
||||
@@ -212,11 +216,25 @@ export function prepareMarkdownSource(
|
||||
if (lineEnd < end) split(lineEnd);
|
||||
}
|
||||
}
|
||||
function visit(node: Nodes) {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) {
|
||||
function visit(node: Nodes, previous?: Nodes) {
|
||||
const position = node.position;
|
||||
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
|
||||
const start = position.start.offset;
|
||||
const end = position.end.offset;
|
||||
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
|
||||
if (
|
||||
node.type === "table" &&
|
||||
previous?.type === "paragraph" &&
|
||||
previous.position?.end.line === position.start.line - 1
|
||||
) {
|
||||
// MD4C only recognizes a table header on the first line of a paragraph.
|
||||
const lineStart =
|
||||
Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1;
|
||||
const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, "");
|
||||
const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd();
|
||||
const boundary = lineStart - newline.length;
|
||||
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
|
||||
}
|
||||
if (node.type === "link") {
|
||||
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
||||
if (title !== null)
|
||||
@@ -244,7 +262,8 @@ export function prepareMarkdownSource(
|
||||
if (text !== source.slice(start, end)) edits.push({ start, end, text });
|
||||
}
|
||||
}
|
||||
if ("children" in node) node.children.forEach(visit);
|
||||
if ("children" in node)
|
||||
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
|
||||
}
|
||||
visit(getTree());
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -12,11 +13,7 @@ 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";
|
||||
@@ -162,20 +159,13 @@ function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
export interface WelcomeScreenProps {
|
||||
onHostAdded?: (profile: HostProfile) => void;
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
export function WelcomeScreen() {
|
||||
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));
|
||||
|
||||
@@ -196,23 +186,10 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
router.push("/settings");
|
||||
}, [router]);
|
||||
|
||||
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 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 actions: WelcomeAction[] =
|
||||
isWeb || isFdroidBuild
|
||||
@@ -316,24 +293,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -56,6 +56,7 @@ import * as Clipboard from "expo-clipboard";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { resolveWorkspaceFilePaths } from "@/workspace/file-open";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import type { GitActions } from "@/git/policy";
|
||||
@@ -1452,6 +1453,10 @@ function useDiffTabNavigation({
|
||||
};
|
||||
}
|
||||
|
||||
function resolveChangesFileRoot(status: CheckoutStatusPayload | null, cwd: string): string {
|
||||
return status?.repoRoot ?? cwd;
|
||||
}
|
||||
|
||||
export function ChangesSurface({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -1631,14 +1636,33 @@ export function ChangesSurface({
|
||||
}),
|
||||
[appSettings.monoFontFamily, codeFontSize, effectiveLayout, wrapLines],
|
||||
);
|
||||
const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd });
|
||||
const fileRoot = resolveChangesFileRoot(status, cwd);
|
||||
const resolveFilePath = useCallback(
|
||||
(path: string) => buildAbsoluteExplorerPath({ workspaceRoot: fileRoot, entryPath: path }),
|
||||
[fileRoot],
|
||||
);
|
||||
const resolveFileTargetPath = useCallback(
|
||||
(path: string) => {
|
||||
const absolutePath = resolveFilePath(path);
|
||||
const resolved = resolveWorkspaceFilePaths({ path: absolutePath, workspaceRoot: cwd });
|
||||
return resolved?.relativePath ?? absolutePath;
|
||||
},
|
||||
[cwd, resolveFilePath],
|
||||
);
|
||||
const handleOpenFile = useCallback(
|
||||
(path: string) => onOpenFile?.(resolveFileTargetPath(path)),
|
||||
[onOpenFile, resolveFileTargetPath],
|
||||
);
|
||||
const handleAddToChat = useCallback(
|
||||
(path: string) => onAddToChat?.(resolveFileTargetPath(path)),
|
||||
[onAddToChat, resolveFileTargetPath],
|
||||
);
|
||||
const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: fileRoot });
|
||||
const handleCopyPath = useCallback(
|
||||
(path: string) => {
|
||||
void Clipboard.setStringAsync(
|
||||
buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
|
||||
);
|
||||
void Clipboard.setStringAsync(resolveFilePath(path));
|
||||
},
|
||||
[cwd],
|
||||
[resolveFilePath],
|
||||
);
|
||||
const handleCopyRelativePath = useCallback((path: string) => {
|
||||
void Clipboard.setStringAsync(path);
|
||||
@@ -1652,7 +1676,7 @@ export function ChangesSurface({
|
||||
await openDesktopTarget({
|
||||
editorId: fileManagerTarget.id,
|
||||
workspacePath: cwd,
|
||||
filePath: buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
|
||||
filePath: resolveFilePath(path),
|
||||
});
|
||||
} catch (cause) {
|
||||
toast.error(
|
||||
@@ -1660,7 +1684,7 @@ export function ChangesSurface({
|
||||
);
|
||||
}
|
||||
},
|
||||
[cwd, fileManagerTarget, t, toast],
|
||||
[cwd, fileManagerTarget, resolveFilePath, t, toast],
|
||||
);
|
||||
const handleDownloadPath = useCallback(
|
||||
(path: string) => {
|
||||
@@ -1674,7 +1698,7 @@ export function ChangesSurface({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await client.duplicateFileEntry({ cwd, path });
|
||||
const payload = await client.duplicateFileEntry({ cwd: fileRoot, path });
|
||||
if (!payload.success) {
|
||||
toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed"));
|
||||
}
|
||||
@@ -1682,7 +1706,7 @@ export function ChangesSurface({
|
||||
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
},
|
||||
[client, cwd, t, toast],
|
||||
[client, fileRoot, t, toast],
|
||||
);
|
||||
const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode });
|
||||
const [localFocusRequest, setLocalFocusRequest] = useState<{
|
||||
@@ -1718,9 +1742,9 @@ export function ChangesSurface({
|
||||
focusPath: documentFocusRequest?.path,
|
||||
focusRequestId: documentFocusRequest?.revision,
|
||||
workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined,
|
||||
onOpenFile,
|
||||
onOpenFile: onOpenFile ? handleOpenFile : undefined,
|
||||
onOpenToSide,
|
||||
onAddToChat,
|
||||
onAddToChat: onAddToChat ? handleAddToChat : undefined,
|
||||
onCopyPath: handleCopyPath,
|
||||
onCopyRelativePath: handleCopyRelativePath,
|
||||
onReveal: fileManagerTarget ? handleRevealPath : undefined,
|
||||
@@ -1736,8 +1760,10 @@ export function ChangesSurface({
|
||||
serverId,
|
||||
workspaceId,
|
||||
onOpenFile,
|
||||
handleOpenFile,
|
||||
onOpenToSide,
|
||||
onAddToChat,
|
||||
handleAddToChat,
|
||||
handleCopyPath,
|
||||
handleCopyRelativePath,
|
||||
handleDownloadPath,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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%"];
|
||||
+11
-10
@@ -1,9 +1,10 @@
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { 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 "./adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { isFdroidBuild } from "@/constants/build-profile";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
@@ -84,7 +85,7 @@ export function AddHostMethodModal({
|
||||
onClose={onClose}
|
||||
testID="add-host-method-modal"
|
||||
>
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityRole="button"
|
||||
@@ -98,10 +99,10 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.direct.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</PressHighlight>
|
||||
|
||||
{isElectronRuntime() ? (
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
style={styles.option}
|
||||
onPress={handleRemoteSsh}
|
||||
accessibilityRole="button"
|
||||
@@ -115,11 +116,11 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.remoteSsh.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</PressHighlight>
|
||||
) : null}
|
||||
|
||||
{isNative && !isFdroidBuild ? (
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
style={styles.option}
|
||||
onPress={handleScan}
|
||||
accessibilityRole="button"
|
||||
@@ -132,10 +133,10 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.scanQr.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</PressHighlight>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityRole="button"
|
||||
@@ -149,7 +150,7 @@ export function AddHostMethodModal({
|
||||
{t("pairing.connectionMethods.pasteLink.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</PressHighlight>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
+47
-30
@@ -1,17 +1,22 @@
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } 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 "./connection-failure-copy";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
import { buildDirectConnectionUri } from "./direct-connection-host";
|
||||
import { PairingTargetTracker } from "@/components/pair-link-credentials";
|
||||
import { buildDirectConnectionUri } from "@/components/direct-connection-host";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
|
||||
@@ -188,7 +193,6 @@ 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();
|
||||
@@ -207,10 +211,7 @@ 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(
|
||||
() => <Link2 size={16} color={theme.colors.accentForeground} />,
|
||||
[theme.colors.accentForeground],
|
||||
);
|
||||
const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
|
||||
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
|
||||
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
|
||||
const checkboxStyle = useMemo(
|
||||
@@ -415,8 +416,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsAdvancedOpen(false);
|
||||
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
|
||||
|
||||
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
|
||||
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
|
||||
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
|
||||
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -430,7 +431,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>
|
||||
<AdaptiveTextInput
|
||||
<ThemedAdaptiveTextInput
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.host")}
|
||||
@@ -438,7 +439,6 @@ 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>
|
||||
<AdaptiveTextInput
|
||||
<ThemedAdaptiveTextInput
|
||||
testID="direct-port-input"
|
||||
nativeID="direct-port-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.port")}
|
||||
@@ -457,7 +457,6 @@ 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}
|
||||
@@ -469,7 +468,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
style={styles.checkboxRow}
|
||||
onPress={handleToggleUseTls}
|
||||
disabled={isSaving}
|
||||
@@ -482,17 +481,17 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<View style={checkboxStyle}>
|
||||
{useTls ? (
|
||||
<View testID="direct-ssl-toggle-checked">
|
||||
<Check size={14} color={theme.colors.accentForeground} />
|
||||
<ThemedCheck size={14} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
|
||||
</Pressable>
|
||||
</PressHighlight>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
|
||||
<View style={styles.passwordRow}>
|
||||
<AdaptiveTextInput
|
||||
<ThemedAdaptiveTextInput
|
||||
testID="direct-password-input"
|
||||
nativeID="direct-password-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.password")}
|
||||
@@ -500,7 +499,6 @@ 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}
|
||||
@@ -509,7 +507,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
/>
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
style={styles.iconButton}
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
@@ -521,13 +519,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
testID="direct-password-visibility-toggle"
|
||||
>
|
||||
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<PasswordIcon size={18} />
|
||||
</PressHighlight>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
style={styles.advancedToggle}
|
||||
onPress={handleToggleAdvanced}
|
||||
disabled={isSaving}
|
||||
@@ -537,11 +535,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
testID="direct-host-advanced-toggle"
|
||||
>
|
||||
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
|
||||
<AdvancedIcon size={16} />
|
||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||
</Pressable>
|
||||
</PressHighlight>
|
||||
{isAdvancedOpen ? (
|
||||
<AdaptiveTextInput
|
||||
<ThemedAdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
@@ -549,7 +547,6 @@ 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}
|
||||
@@ -585,3 +582,23 @@ 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,
|
||||
}));
|
||||
+5
-5
@@ -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 "./adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/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>
|
||||
<Pressable
|
||||
<PressHighlight
|
||||
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} />}
|
||||
</Pressable>
|
||||
</PressHighlight>
|
||||
</View>
|
||||
</Field>
|
||||
<View style={styles.actions}>
|
||||
@@ -0,0 +1,49 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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) });
|
||||
},
|
||||
};
|
||||
}
|
||||
+16
-11
@@ -1,16 +1,20 @@
|
||||
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, withUnistyles } 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 "./adaptive-modal-sheet";
|
||||
import {
|
||||
AdaptiveModalSheet,
|
||||
AdaptiveTextInput,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
import { PairingTargetTracker } from "@/components/pair-link-credentials";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
|
||||
@@ -100,7 +104,6 @@ function PairLinkModalContent({
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { beginLinkPairing } = useHostMutations();
|
||||
@@ -126,10 +129,7 @@ function PairLinkModalContent({
|
||||
resetPasswordInput();
|
||||
}, []);
|
||||
|
||||
const pairIcon = useMemo(
|
||||
() => <Link size={16} color={theme.colors.accentForeground} />,
|
||||
[theme.colors.accentForeground],
|
||||
);
|
||||
const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
|
||||
|
||||
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>
|
||||
<AdaptiveTextInput
|
||||
<ThemedAdaptiveTextInput
|
||||
ref={inputRef}
|
||||
initialValue={initialUrl}
|
||||
testID="pair-link-input"
|
||||
@@ -220,7 +220,6 @@ 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"
|
||||
@@ -235,7 +234,7 @@ function PairLinkModalContent({
|
||||
<Text style={styles.label}>
|
||||
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
|
||||
</Text>
|
||||
<AdaptiveTextInput
|
||||
<ThemedAdaptiveTextInput
|
||||
testID="pair-link-password-input"
|
||||
resetKey={`pair-link-password-${passwordResetKey}`}
|
||||
accessibilityLabel={t("pairing.hostPassword.label")}
|
||||
@@ -274,3 +273,9 @@ function PairLinkModalContent({
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
|
||||
|
||||
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
+24
-59
@@ -1,17 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } 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 "@/hosts/pair-scan-model";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { openPairScan } from "./pair-scan-model";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
export function PairScanContent({
|
||||
onConnected,
|
||||
onClose,
|
||||
}: {
|
||||
onConnected: (serverId: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
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,
|
||||
source === "onboarding" ? "hostRoot" : "hostSettings",
|
||||
),
|
||||
onConnected: navigateToPairedHost,
|
||||
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
|
||||
onConnected,
|
||||
}),
|
||||
);
|
||||
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;
|
||||
@@ -169,35 +142,29 @@ export default function PairScanScreen() {
|
||||
[scan],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const savePasswordPairing = useCallback(
|
||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||
[navigateToPairedHost],
|
||||
({ serverId }: { serverId: string }) => onConnected(serverId),
|
||||
[onConnected],
|
||||
);
|
||||
const handleRequestPermission = useCallback(() => {
|
||||
void requestPermission();
|
||||
}, [requestPermission]);
|
||||
|
||||
const bodyStyle = useMemo(
|
||||
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
|
||||
[insets.bottom, theme.spacing],
|
||||
);
|
||||
const helperTextStyle = useMemo(
|
||||
() => [styles.helperText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
() => [styles.body, { paddingBottom: insets.bottom + 24 }],
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
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>
|
||||
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
|
||||
</Pressable>
|
||||
<Button style={styles.permissionButton} onPress={onClose}>
|
||||
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -208,16 +175,14 @@ export default function PairScanScreen() {
|
||||
|
||||
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>
|
||||
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Button style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
|
||||
</Pressable>
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.cameraWrap}>
|
||||
@@ -235,10 +200,10 @@ export default function PairScanScreen() {
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
{scanState.status === "pairing" ? (
|
||||
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
|
||||
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" && scanState.error ? (
|
||||
<Text style={helperTextStyle}>{scanState.error}</Text>
|
||||
<Text style={styles.helperText}>{scanState.error}</Text>
|
||||
) : null}
|
||||
{scanState.status === "stopped" ? (
|
||||
<Button
|
||||
@@ -1,12 +1,11 @@
|
||||
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";
|
||||
@@ -20,7 +19,6 @@ 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>;
|
||||
@@ -65,6 +63,7 @@ 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);
|
||||
@@ -76,13 +75,7 @@ export function useHostChooser() {
|
||||
);
|
||||
|
||||
if (availableHosts.length === 0) {
|
||||
(
|
||||
input.onNoHosts ??
|
||||
(() => {
|
||||
usePanelStore.getState().showMobileAgent();
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
})
|
||||
)();
|
||||
(input.onNoHosts ?? addHost)();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,7 +91,7 @@ export function useHostChooser() {
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[hosts, localServerId, open],
|
||||
[hosts, localServerId, open, addHost],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,45 @@ 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,13 +22,14 @@ function cloneAndFreeze<T>(value: T): T {
|
||||
}
|
||||
|
||||
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
|
||||
const { callId, name, status, error, detail, metadata } = data;
|
||||
const { callId, name, status, error, detail, metadata, agentMessage } = data;
|
||||
const base = {
|
||||
type: "tool_call" as const,
|
||||
callId,
|
||||
name,
|
||||
detail,
|
||||
...(metadata ? { metadata } : {}),
|
||||
...(agentMessage ? { agentMessage } : {}),
|
||||
};
|
||||
switch (status) {
|
||||
case "running":
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import type {
|
||||
CreateAgentRequestOptions,
|
||||
CreateWorkspaceRequestOptions,
|
||||
@@ -8,7 +9,6 @@ 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,7 +62,6 @@ 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,
|
||||
@@ -1166,9 +1165,7 @@ export function NewWorkspaceScreen({
|
||||
openAddProjectPicker(selectedServerId);
|
||||
}, [openAddProjectPicker, selectedServerId]);
|
||||
|
||||
const handleAddHost = useCallback(() => {
|
||||
router.push(buildSettingsAddHostRoute(Date.now()));
|
||||
}, []);
|
||||
const handleAddHost = useAddHost();
|
||||
|
||||
const openPicker = useCallback(() => {
|
||||
setPickerOpen(true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAddHost } from "@/hosts/add-host";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -74,10 +75,6 @@ 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";
|
||||
@@ -1089,21 +1086,15 @@ function SettingsSidebar({
|
||||
|
||||
export interface SettingsScreenProps {
|
||||
view: SettingsView;
|
||||
openAddHostIntent?: string | null;
|
||||
}
|
||||
|
||||
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
|
||||
export default function SettingsScreen({ view }: 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);
|
||||
@@ -1164,50 +1155,10 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
}
|
||||
}, [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 }: { serverId: string }) => {
|
||||
const target = buildSettingsHostSectionRoute(serverId, "connections");
|
||||
(serverId: string) => {
|
||||
selectSettingsHost(serverId);
|
||||
const target = buildSettingsHostSectionRoute(serverId, "host");
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
@@ -1217,6 +1168,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
[isCompactLayout, router],
|
||||
);
|
||||
|
||||
const handleAddHost = useAddHost({ onAdded: handleHostAdded });
|
||||
|
||||
const handleSelectSection = useCallback(
|
||||
(section: SettingsSectionSlug) => {
|
||||
const target = buildSettingsSectionRoute(section);
|
||||
@@ -1272,14 +1225,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
[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) {
|
||||
@@ -1388,37 +1333,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -1436,7 +1350,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
layout="mobile"
|
||||
/>
|
||||
</ScrollView>
|
||||
{addHostModals}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1447,7 +1360,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
|
||||
{content}
|
||||
</PageLayout>
|
||||
{addHostModals}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1482,7 +1394,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
</View>
|
||||
{addHostModals}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
buildOpenProjectRoute,
|
||||
resolveKnownHostRoute,
|
||||
buildSessionsRoute,
|
||||
buildSettingsAddHostRoute,
|
||||
buildProjectSettingsRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
@@ -185,14 +184,6 @@ 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");
|
||||
});
|
||||
|
||||
@@ -553,10 +553,6 @@ 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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ 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";
|
||||
@@ -198,6 +199,20 @@ 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" });
|
||||
@@ -1208,6 +1223,7 @@ async function main() {
|
||||
const page = await waitForAppPage(browser, expoPort);
|
||||
await hideDeveloperToasts(page);
|
||||
const status = await waitForDesktopStatus(page);
|
||||
await settleDesktopStartup(page);
|
||||
|
||||
const checkPluginLinks = () =>
|
||||
runPluginLinksRegression({
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
||||
availableModes: OPENCODE_MODES,
|
||||
}),
|
||||
).toThrow(
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
||||
availableModes: CODEX_MODES,
|
||||
}),
|
||||
).toThrow(
|
||||
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
|
||||
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
||||
targetUnattendedMode: "full-access",
|
||||
}),
|
||||
).toThrow(
|
||||
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
|
||||
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
||||
targetUnattendedMode: undefined,
|
||||
}),
|
||||
).toThrow(
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
|
||||
|
||||
interface ScriptedTurn {
|
||||
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
|
||||
errorReply?: string;
|
||||
// Assistant text the model wrote before or after it.
|
||||
before?: string;
|
||||
after?: string;
|
||||
isError: boolean;
|
||||
usage?: {
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCostUsd: number;
|
||||
};
|
||||
}
|
||||
|
||||
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
|
||||
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
|
||||
return ({ prompt }) => {
|
||||
let index = 0;
|
||||
const assistant = (id: string, text: string, error?: string) => ({
|
||||
type: "assistant",
|
||||
session_id: "error-session",
|
||||
parent_tool_use_id: null,
|
||||
uuid: id,
|
||||
message: {
|
||||
id,
|
||||
role: "assistant",
|
||||
model: error ? "<synthetic>" : "claude-opus-5-5",
|
||||
content: [{ type: "text", text }],
|
||||
stop_reason: "stop_sequence",
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
...(error ? { error } : {}),
|
||||
});
|
||||
const messages = (async function* () {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
for await (const _message of prompt) {
|
||||
const turn = turns[index];
|
||||
index += 1;
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "error-session",
|
||||
permissionMode: "default",
|
||||
model: "claude-opus-5-5",
|
||||
};
|
||||
if (turn.before) yield assistant(`before-${index}`, turn.before);
|
||||
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
|
||||
if (turn.after) yield assistant(`after-${index}`, turn.after);
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "error-session",
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 0,
|
||||
is_error: turn.isError,
|
||||
num_turns: 1,
|
||||
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
|
||||
stop_reason: "stop_sequence",
|
||||
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
|
||||
usage: {
|
||||
input_tokens: turn.usage?.inputTokens ?? 0,
|
||||
output_tokens: turn.usage?.outputTokens ?? 0,
|
||||
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: `result-${index}`,
|
||||
};
|
||||
}
|
||||
})();
|
||||
return Object.assign(messages, {
|
||||
close: () => {
|
||||
void messages.return();
|
||||
},
|
||||
interrupt: async () => undefined,
|
||||
setModel: async () => undefined,
|
||||
setPermissionMode: async () => undefined,
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
}) as ReturnType<ClaudeQueryFactory>;
|
||||
};
|
||||
}
|
||||
|
||||
async function runTurns(turns: ScriptedTurn[]) {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: scriptedQuery(turns),
|
||||
resolveBinary: async () => process.execPath,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
try {
|
||||
const results = [];
|
||||
for (const [index] of turns.entries()) {
|
||||
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
|
||||
}
|
||||
return results;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
|
||||
return events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
|
||||
return events.find(
|
||||
(event) =>
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled",
|
||||
);
|
||||
}
|
||||
|
||||
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
|
||||
const [limited, overloaded, next] = await runTurns([
|
||||
{ errorReply: LIMIT, isError: true },
|
||||
{
|
||||
before: "Checking the deploy doc.",
|
||||
errorReply: "API Error: 529 Overloaded",
|
||||
isError: true,
|
||||
},
|
||||
{ after: "Done.", isError: false },
|
||||
]);
|
||||
|
||||
expect(terminal(limited)).toMatchObject({
|
||||
type: "turn_failed",
|
||||
error: LIMIT,
|
||||
});
|
||||
expect(assistantTexts(limited)).toEqual([]);
|
||||
|
||||
expect(terminal(overloaded)).toMatchObject({
|
||||
type: "turn_failed",
|
||||
error: "API Error: 529 Overloaded",
|
||||
});
|
||||
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
|
||||
|
||||
// The session keeps working after a failed turn.
|
||||
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(next)).toEqual(["Done."]);
|
||||
});
|
||||
|
||||
test("a failed turn retains its final token counts and cost", async () => {
|
||||
const usage = {
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 80,
|
||||
outputTokens: 30,
|
||||
totalCostUsd: 0.012,
|
||||
};
|
||||
const [failed] = await runTurns([
|
||||
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
|
||||
]);
|
||||
|
||||
expect(failed).toContainEqual(
|
||||
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
|
||||
);
|
||||
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
|
||||
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
|
||||
});
|
||||
|
||||
test("an error reply the turn moves past is still shown", async () => {
|
||||
const [recovered, completed] = await runTurns([
|
||||
{
|
||||
errorReply: "API Error: 529 Overloaded",
|
||||
after: "Recovered.",
|
||||
isError: false,
|
||||
},
|
||||
{ errorReply: LIMIT, isError: false },
|
||||
]);
|
||||
|
||||
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
|
||||
|
||||
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
|
||||
expect(assistantTexts(completed)).toEqual([LIMIT]);
|
||||
});
|
||||
@@ -2389,6 +2389,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private mainTurnInFlight = false;
|
||||
private foregroundHasVisibleActivity = false;
|
||||
private activeTurnHasAssistantText = false;
|
||||
/**
|
||||
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
|
||||
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
|
||||
* failed turn shows the same text as its error, so showing both would repeat it.
|
||||
*/
|
||||
private withheldErrorReply: AgentTimelineItem[] = [];
|
||||
private readonly contextUsage: ClaudeContextUsageState;
|
||||
private userMessageIds: string[] = [];
|
||||
private readonly emittedUserMessageIds = new Set<string>();
|
||||
@@ -2531,6 +2537,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.foregroundHasVisibleActivity = false;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.withheldErrorReply = [];
|
||||
this.contextUsage.beginTurn();
|
||||
this.transitionTurnState("foreground", "foreground turn started");
|
||||
this.clearRecentStderr();
|
||||
@@ -4419,6 +4426,21 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}) satisfies AgentStreamEvent,
|
||||
);
|
||||
|
||||
if (!readClaudeParentToolUseId(message)) {
|
||||
if (message.type === "assistant" && message.error) {
|
||||
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
|
||||
return messageEvents;
|
||||
}
|
||||
if (
|
||||
message.type === "assistant" ||
|
||||
message.type === "user" ||
|
||||
message.type === "stream_event"
|
||||
) {
|
||||
const released: AgentStreamEvent[] = [];
|
||||
this.releaseWithheldErrorReply(released);
|
||||
return [...released, ...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
}
|
||||
return [...messageEvents, ...assistantTimelineEvents];
|
||||
}
|
||||
|
||||
@@ -4966,6 +4988,23 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const usage = this.convertUsage(message);
|
||||
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
|
||||
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
|
||||
if (message.subtype === "success" && message.is_error) {
|
||||
const withheld = this.withheldErrorReply;
|
||||
this.withheldErrorReply = [];
|
||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||
const withheldText = withheld
|
||||
.map((item) => (item.type === "assistant_message" ? item.text : ""))
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
|
||||
events.push(...this.sidechainTracker.finishAll("failed"));
|
||||
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
|
||||
return;
|
||||
}
|
||||
const releasedErrorReply = this.withheldErrorReply.length > 0;
|
||||
this.releaseWithheldErrorReply(events);
|
||||
if (message.subtype === "success") {
|
||||
events.push(...this.sidechainTracker.finishAll("completed"));
|
||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||
@@ -4975,7 +5014,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// accounting from provider gateways does not duplicate streamed output.
|
||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||
const outputTokens = message.usage?.output_tokens;
|
||||
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
|
||||
if (
|
||||
resultText.length > 0 &&
|
||||
outputTokens === 0 &&
|
||||
!this.activeTurnHasAssistantText &&
|
||||
!releasedErrorReply
|
||||
) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
@@ -4997,6 +5041,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events.push(this.buildTurnFailedEvent(errorMessage));
|
||||
}
|
||||
|
||||
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
|
||||
if (this.withheldErrorReply.length === 0) return;
|
||||
for (const item of this.withheldErrorReply) {
|
||||
events.push({ type: "timeline", item, provider: "claude" });
|
||||
}
|
||||
this.withheldErrorReply = [];
|
||||
}
|
||||
|
||||
private createClaudeSessionChangedNotice(
|
||||
oldSessionId: string,
|
||||
newSessionId: string,
|
||||
|
||||
@@ -150,6 +150,15 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
|
||||
|
||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
||||
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
|
||||
// Each terminate is bounded by the first constant. A turn start waits for the
|
||||
// interrupted turn's commands to end, at most the interrupt's own bound plus one
|
||||
// terminate's and a margin, so it gives up only when a request outlives its timeout.
|
||||
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
|
||||
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
|
||||
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
|
||||
// Turns whose commands are remembered; older turns are forgotten first.
|
||||
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
|
||||
const CODEX_PROVIDER = "codex" as const;
|
||||
// These native provider identities belong to Codex, not to an older custom
|
||||
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
||||
@@ -3476,6 +3485,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||
private pendingAssistantMessageBoundary = false;
|
||||
private terminalCommandByProcessId = new Map<string, string>();
|
||||
// The root thread's still-running command process ids, keyed by the native turn that
|
||||
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
|
||||
// `item/started` is the only record of which turn owns a process.
|
||||
private terminalProcessIdsByTurnId = new Map<
|
||||
string,
|
||||
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
|
||||
>();
|
||||
private interruptedTurnCleanup: Promise<void> | null = null;
|
||||
private pendingUnlabeledTerminalInteractions = new Map<
|
||||
string,
|
||||
Array<{ callId: string; stdin: string | null }>
|
||||
@@ -4431,6 +4448,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||
hasCodexConfig: turnStart.hasCodexConfig,
|
||||
});
|
||||
// The interrupted turn's commands end before this turn begins, so none of them
|
||||
// outlives it into the new turn.
|
||||
await this.waitForInterruptedTurnCleanup();
|
||||
if (pendingStart.cancelRequested) {
|
||||
throw new Error("Codex turn start was interrupted before reaching Codex");
|
||||
}
|
||||
@@ -5036,15 +5056,27 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
||||
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
||||
}
|
||||
const client = this.client;
|
||||
const threadId = this.currentThreadId;
|
||||
const interruptRequest = client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId,
|
||||
turnId,
|
||||
},
|
||||
INTERRUPT_TIMEOUT_MS,
|
||||
);
|
||||
// Once Codex accepts the interrupt, end the commands this turn started. Registered
|
||||
// before the await, so a turn start racing this interrupt waits for it. An already-idle
|
||||
// turn was not interrupted, and what it left running stays.
|
||||
this.trackInterruptedTurnCleanup(
|
||||
interruptRequest.then(
|
||||
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await this.client.request(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: this.currentThreadId,
|
||||
turnId,
|
||||
},
|
||||
INTERRUPT_TIMEOUT_MS,
|
||||
);
|
||||
await interruptRequest;
|
||||
} catch (error) {
|
||||
if (!isCodexAlreadyIdleInterrupt(error)) {
|
||||
throw error;
|
||||
@@ -5085,6 +5117,137 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return !this.client.hasPendingRequests();
|
||||
}
|
||||
|
||||
// Remember which turn started a command, so an interrupt ends that turn's commands and
|
||||
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
|
||||
// A command that starts after its turn's interrupt was accepted is ended as soon as it
|
||||
// is seen.
|
||||
private recordTurnTerminalProcess(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
|
||||
): void {
|
||||
const processId = parsed.item.processId;
|
||||
if (
|
||||
parsed.source !== "item" ||
|
||||
!parsed.turnId ||
|
||||
!parsed.threadId ||
|
||||
parsed.threadId !== this.currentThreadId ||
|
||||
typeof processId !== "string" ||
|
||||
!processId ||
|
||||
normalizeCodexThreadItemType(
|
||||
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
|
||||
) !== "commandExecution"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
|
||||
if (parsed.kind === "item_completed") {
|
||||
// Only a completed command has exited. A failed one can still be running:
|
||||
// Codex reports an interrupted turn's live command as failed.
|
||||
if (parsed.item.status === "completed") record?.processIds.delete(processId);
|
||||
return;
|
||||
}
|
||||
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
|
||||
current.processIds.add(processId);
|
||||
if (current.interruptAccepted && this.client) {
|
||||
this.trackInterruptedTurnCleanup(
|
||||
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// An accepted interrupt needs a record even before any of its turn's commands is seen:
|
||||
// an `item/started` can still be queued behind an earlier notification's conversion.
|
||||
private getTurnTerminalRecord(turnId: string) {
|
||||
const existing = this.terminalProcessIdsByTurnId.get(turnId);
|
||||
if (existing) return existing;
|
||||
const record = {
|
||||
processIds: new Set<string>(),
|
||||
terminatedProcessIds: new Set<string>(),
|
||||
interruptAccepted: false,
|
||||
};
|
||||
this.terminalProcessIdsByTurnId.set(turnId, record);
|
||||
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
|
||||
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
|
||||
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
// A later sweep joins the pending one, so a turn start waits for both.
|
||||
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
|
||||
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
|
||||
});
|
||||
this.interruptedTurnCleanup = tracked;
|
||||
}
|
||||
|
||||
// Terminate each process the interrupted turn started, by its own id, once each.
|
||||
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
|
||||
// an earlier turn's included. A process already gone answers `terminated: false`. Never
|
||||
// rejects; failures are logged.
|
||||
private async endInterruptedTurnProcesses(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string | null,
|
||||
turnId: string,
|
||||
): Promise<void> {
|
||||
if (!threadId) return;
|
||||
const record = this.getTurnTerminalRecord(turnId);
|
||||
record.interruptAccepted = true;
|
||||
const processIds = [...record.processIds].filter(
|
||||
(processId) => !record.terminatedProcessIds.has(processId),
|
||||
);
|
||||
for (const processId of processIds) record.terminatedProcessIds.add(processId);
|
||||
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
|
||||
await Promise.all(
|
||||
processIds.map(async (processId) => {
|
||||
try {
|
||||
const response = await client.request(
|
||||
"thread/backgroundTerminals/terminate",
|
||||
{ threadId, processId },
|
||||
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
|
||||
);
|
||||
this.logger.info(
|
||||
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
|
||||
"provider.codex.interrupt.terminal_terminated",
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ ...logContext, processId, err: error },
|
||||
"provider.codex.interrupt.terminal_terminate_failed",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// A new turn starts only after every pending sweep of interrupted turns' commands has
|
||||
// ended, or after the bound, whichever is first.
|
||||
private async waitForInterruptedTurnCleanup(): Promise<void> {
|
||||
if (!this.interruptedTurnCleanup) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = await Promise.race([
|
||||
(async () => {
|
||||
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
|
||||
return false;
|
||||
})(),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
|
||||
}),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
this.logger.warn(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
provider: CODEX_PROVIDER,
|
||||
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
|
||||
},
|
||||
"provider.codex.interrupt.terminal_cleanup_timed_out",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.clearPendingPermissions();
|
||||
@@ -6209,6 +6372,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.completePendingRootCompactions();
|
||||
// What a turn that was not interrupted leaves running, it left on purpose; no later
|
||||
// interrupt may end it.
|
||||
if (parsed.status !== "interrupted" && this.currentTurnId) {
|
||||
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
|
||||
}
|
||||
if (parsed.status === "failed") {
|
||||
this.emitEvent({
|
||||
type: "turn_failed",
|
||||
@@ -6613,6 +6781,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private async handleItemCompletedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||
): Promise<void> {
|
||||
this.recordTurnTerminalProcess(parsed);
|
||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||
// and canonical `item/*`. Render ordinary items only from the canonical
|
||||
// channel, but accept a legacy-only child announcement so it can establish
|
||||
@@ -6775,6 +6944,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private async handleItemStartedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
||||
): Promise<void> {
|
||||
this.recordTurnTerminalProcess(parsed);
|
||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { AgentSession } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
waitForNextEvent,
|
||||
waitForTimelineToolCall,
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
|
||||
const THREAD = "thread-1";
|
||||
const TERMINATE = "thread/backgroundTerminals/terminate";
|
||||
|
||||
type Handler = (params: unknown) => unknown;
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function paramsOf(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
let sessions: AgentSession[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions) await session.close().catch(() => undefined);
|
||||
sessions = [];
|
||||
});
|
||||
|
||||
/**
|
||||
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
|
||||
* turn completion, then the response. `terminate` answers per process.
|
||||
*/
|
||||
function createHarness(
|
||||
options: {
|
||||
terminate?: (processId: string) => unknown;
|
||||
interruptResponse?: (completeTurn: () => void) => unknown;
|
||||
} = {},
|
||||
) {
|
||||
const handlers: Record<string, Handler> = {
|
||||
"turn/interrupt": () => {
|
||||
const completeTurn = () =>
|
||||
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
|
||||
if (options.interruptResponse) return options.interruptResponse(completeTurn);
|
||||
completeTurn();
|
||||
return {};
|
||||
},
|
||||
[TERMINATE]: (params) =>
|
||||
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
|
||||
};
|
||||
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: "/workspace/project",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
sessions.push(session);
|
||||
|
||||
function requests(method: string): Record<string, unknown>[] {
|
||||
return appServer
|
||||
.requests()
|
||||
.filter((message) => message.method === method && "id" in message)
|
||||
.map((message) => paramsOf(message.params));
|
||||
}
|
||||
|
||||
function terminated(): string[] {
|
||||
return requests(TERMINATE)
|
||||
.map((params) => String(params.processId))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Starts a turn and lets Codex identify it as `nativeTurnId`.
|
||||
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
|
||||
const before = requests("turn/start").length;
|
||||
const started = session.startTurn(prompt);
|
||||
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
|
||||
timeout: 5_000,
|
||||
});
|
||||
const turnStarted = waitForNextEvent(session, "turn_started");
|
||||
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
|
||||
await started;
|
||||
await turnStarted;
|
||||
}
|
||||
|
||||
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
|
||||
const itemId = `exec-${params.processId}`;
|
||||
const shown = waitForTimelineToolCall(session, itemId);
|
||||
appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: params.turnId,
|
||||
itemId,
|
||||
processId: params.processId,
|
||||
command: `sleep ${params.processId}`,
|
||||
});
|
||||
await shown;
|
||||
}
|
||||
|
||||
async function completesTurn(): Promise<void> {
|
||||
const completed = waitForNextEvent(session, "turn_completed");
|
||||
appServer.completeTurn({ threadId: THREAD });
|
||||
await completed;
|
||||
}
|
||||
|
||||
// Interrupts the running turn and waits for the session to report it canceled, as a
|
||||
// caller does before it starts the next turn: Codex notifications are handled in order
|
||||
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
|
||||
async function interrupts(): Promise<void> {
|
||||
const canceled = waitForNextEvent(session, "turn_canceled");
|
||||
await session.interrupt();
|
||||
await canceled;
|
||||
}
|
||||
|
||||
return {
|
||||
appServer,
|
||||
session,
|
||||
requests,
|
||||
terminated,
|
||||
startTurn,
|
||||
runsCommand,
|
||||
completesTurn,
|
||||
interrupts,
|
||||
};
|
||||
}
|
||||
|
||||
// Turn A leaves a server running on purpose and completes. Turn B starts two
|
||||
// commands, and a subagent on its own thread starts a third, then B is interrupted.
|
||||
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
|
||||
await harness.startTurn("start a server", "native-A");
|
||||
await harness.runsCommand({ turnId: "native-A", processId: "101" });
|
||||
await harness.completesTurn();
|
||||
|
||||
await harness.startTurn("run two commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
// A subagent thread's command shows no row here; the next command's row shows it
|
||||
// was read, because notifications are read in order.
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: "child-thread",
|
||||
turnId: "native-B",
|
||||
itemId: "exec-209",
|
||||
processId: "209",
|
||||
command: "sleep 209",
|
||||
});
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
||||
await harness.interrupts();
|
||||
}
|
||||
|
||||
describe("an interrupted Codex turn's commands end before the next turn", () => {
|
||||
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
|
||||
const harness = createHarness();
|
||||
await interruptTurnB(harness);
|
||||
await harness.startTurn("correction", "native-C");
|
||||
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
|
||||
// `clean` would end every terminal on the thread, an earlier turn's included.
|
||||
expect(
|
||||
harness.appServer
|
||||
.requests()
|
||||
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
|
||||
.map((message) => message.method),
|
||||
).toEqual([TERMINATE, TERMINATE]);
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
|
||||
const acknowledge = deferred();
|
||||
const harness = createHarness({
|
||||
// Codex ends B, starts a turn of its own (a goal continuation), and only then
|
||||
// answers the interrupt.
|
||||
interruptResponse: (completeTurn) => {
|
||||
completeTurn();
|
||||
return acknowledge.promise.then(() => ({}));
|
||||
},
|
||||
});
|
||||
await harness.startTurn("run two commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
||||
const interrupting = harness.session.interrupt();
|
||||
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
|
||||
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
|
||||
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
|
||||
await autonomousTurn;
|
||||
await harness.runsCommand({ turnId: "native-C", processId: "301" });
|
||||
|
||||
acknowledge.resolve();
|
||||
await interrupting;
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
});
|
||||
|
||||
test("sends the next turn/start only after the terminates are answered", async () => {
|
||||
const answer = deferred();
|
||||
const harness = createHarness({
|
||||
terminate: () => answer.promise.then(() => ({ terminated: true })),
|
||||
});
|
||||
await interruptTurnB(harness);
|
||||
const next = harness.startTurn("correction", "native-C");
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(harness.requests("turn/start")).toHaveLength(2);
|
||||
|
||||
answer.resolve();
|
||||
await next;
|
||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
||||
});
|
||||
|
||||
test.each(["initial", "late"] as const)(
|
||||
"tracks both cleanup sweeps when the %s answer arrives first",
|
||||
async (answeredFirst) => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
const harness = createHarness({
|
||||
terminate: (processId) =>
|
||||
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
|
||||
});
|
||||
await harness.startTurn("run commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
await harness.interrupts();
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
|
||||
const next = harness.startTurn("correction", "native-C");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||
// The next turn is already waiting when another process is announced.
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "late-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "duplicate-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
(answeredFirst === "initial" ? first : second).resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||
(answeredFirst === "initial" ? second : first).resolve();
|
||||
await next;
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
// Accepted state survives the completed sweep and a newer foreground turn.
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "late-203",
|
||||
processId: "203",
|
||||
command: "sleep 203",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
|
||||
harness.appServer.assertNoErrors();
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
|
||||
const first = deferred();
|
||||
const harness = createHarness({
|
||||
terminate: (processId) =>
|
||||
processId === "201"
|
||||
? first.promise.then(() => ({ terminated: true }))
|
||||
: { terminated: true },
|
||||
});
|
||||
await harness.startTurn("run commands", "native-B");
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
||||
await harness.interrupts();
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
itemId: "late-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
||||
const next = harness.startTurn("correction", "native-C");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
||||
first.resolve();
|
||||
await next;
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("forgets completed commands but retains failed commands at interrupt", async () => {
|
||||
const harness = createHarness();
|
||||
await harness.startTurn("run commands", "native-B");
|
||||
for (const processId of ["201", "202"]) {
|
||||
await harness.runsCommand({ turnId: "native-B", processId });
|
||||
harness.appServer.child.stdout.write(
|
||||
JSON.stringify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: THREAD,
|
||||
turnId: "native-B",
|
||||
item: {
|
||||
id: `exec-${processId}`,
|
||||
type: "commandExecution",
|
||||
processId,
|
||||
command: `sleep ${processId}`,
|
||||
status: processId === "201" ? "completed" : "failed",
|
||||
exitCode: processId === "201" ? 0 : null,
|
||||
},
|
||||
},
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
// A following command's row is a barrier for both completion handlers.
|
||||
await harness.runsCommand({ turnId: "native-B", processId: "203" });
|
||||
await harness.interrupts();
|
||||
await harness.startTurn("correction", "native-C");
|
||||
expect(harness.terminated()).toEqual(["202", "203"]);
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("retains accepted interrupts for only the latest sixteen turns", async () => {
|
||||
const harness = createHarness();
|
||||
for (let index = 0; index < 17; index++) {
|
||||
await harness.startTurn("run a command", `native-${index}`);
|
||||
await harness.interrupts();
|
||||
}
|
||||
await harness.startTurn("correction", "native-current");
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-0",
|
||||
itemId: "evicted-201",
|
||||
processId: "201",
|
||||
command: "sleep 201",
|
||||
});
|
||||
harness.appServer.startsTerminalCommand({
|
||||
threadId: THREAD,
|
||||
turnId: "native-16",
|
||||
itemId: "retained-202",
|
||||
processId: "202",
|
||||
command: "sleep 202",
|
||||
});
|
||||
await harness.runsCommand({ turnId: "native-current", processId: "301" });
|
||||
expect(harness.terminated()).toEqual(["202"]);
|
||||
harness.appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
|
||||
const harness = createHarness({
|
||||
// 201 is refused while the next turn waits; 202 is never answered.
|
||||
terminate: (processId) =>
|
||||
processId === "201"
|
||||
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
|
||||
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
|
||||
}))
|
||||
: new Promise(() => {}),
|
||||
});
|
||||
await interruptTurnB(harness);
|
||||
const startedAt = Date.now();
|
||||
await harness.startTurn("correction", "native-C");
|
||||
|
||||
expect(Date.now() - startedAt).toBeLessThan(5_000);
|
||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -4,6 +4,102 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||
import type { V2Api } from "./api.js";
|
||||
import { OpenCodeV2AgentClient } from "./agent.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
|
||||
|
||||
describe("OpenCode v2 denial reasons", () => {
|
||||
test.each<{
|
||||
response: AgentPermissionResponse;
|
||||
decision: "reject" | "once" | "always";
|
||||
message?: string;
|
||||
}>([
|
||||
{
|
||||
response: { behavior: "deny", message: "Use the existing file instead." },
|
||||
decision: "reject",
|
||||
message: "Use the existing file instead.",
|
||||
},
|
||||
{ response: { behavior: "deny" }, decision: "reject" },
|
||||
{ response: { behavior: "allow" }, decision: "once" },
|
||||
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
|
||||
])("preserves a permission response: $response", async ({ response, decision, message }) => {
|
||||
const harness = new V2Harness();
|
||||
let pending = true;
|
||||
harness.api.permission.list = async () =>
|
||||
pending
|
||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||
: [];
|
||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||
harness.api.permission.reply = async (input) => {
|
||||
replies.push(input);
|
||||
pending = false;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
await session.respondToPermission("approval", response);
|
||||
expect(replies).toEqual([
|
||||
{
|
||||
sessionID: "session",
|
||||
requestID: "approval",
|
||||
decision,
|
||||
...(message === undefined ? {} : { message }),
|
||||
},
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"explains cards superseded by a steer when clearing is %s",
|
||||
async (clearPendingPermissions) => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
let pending = true;
|
||||
harness.api.permission.list = async () =>
|
||||
pending
|
||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
||||
: [];
|
||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
||||
harness.api.permission.reply = async (input) => {
|
||||
replies.push(input);
|
||||
pending = false;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
const { turnId } = await session.startTurn("Run pwd");
|
||||
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
|
||||
await expect(
|
||||
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
|
||||
expect(replies).toEqual(
|
||||
clearPendingPermissions
|
||||
? [
|
||||
{
|
||||
sessionID: "session",
|
||||
requestID: "approval",
|
||||
decision: "reject",
|
||||
message:
|
||||
"The user answered with a message instead of approving. Their message follows.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("OpenCode v2 questions", () => {
|
||||
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
|
||||
|
||||
@@ -68,6 +68,9 @@ export class SessionPermissions {
|
||||
sessionID: this.permissionOwners.get(requestId) ?? this.id,
|
||||
requestID: requestId,
|
||||
decision: permissionReply(response),
|
||||
...(response.behavior === "deny" && response.message !== undefined
|
||||
? { message: response.message }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
this.resolvePending(requestId, response);
|
||||
|
||||
@@ -100,7 +100,11 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
),
|
||||
clearPermissions: async () => {
|
||||
for (const request of this.permissions.list())
|
||||
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
|
||||
await this.permissions.respondToPermission(request.id, {
|
||||
behavior: "deny",
|
||||
message:
|
||||
"The user answered with a message instead of approving. Their message follows.",
|
||||
});
|
||||
},
|
||||
});
|
||||
this.usage = new SessionUsage({
|
||||
|
||||
@@ -1590,7 +1590,7 @@ function createToolCatalog(
|
||||
{
|
||||
title: "Create agent",
|
||||
description:
|
||||
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
|
||||
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.",
|
||||
inputSchema: createAgentInputSchema,
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { expect, test } from "vitest";
|
||||
import { createDaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
test("an attended MCP caller can discover and supply a cross-provider child mode", async () => {
|
||||
const artifacts = path.join(process.cwd(), ".artifacts");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const root = await mkdtemp(path.join(artifacts, "create-agent-mode-"));
|
||||
const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true });
|
||||
const mcp = new Client({ name: "mode-contract-test", version: "1" });
|
||||
try {
|
||||
const parent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: root,
|
||||
modeId: "default",
|
||||
title: "Attended parent",
|
||||
});
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`),
|
||||
),
|
||||
);
|
||||
const { tools } = await mcp.listTools();
|
||||
const create = tools.find((tool) => tool.name === "create_agent");
|
||||
expect.soft(create?.description).toContain("settings.modeId");
|
||||
|
||||
const args = {
|
||||
provider: "codex/gpt-5.4-mini",
|
||||
title: "Cross-provider child",
|
||||
initialPrompt: "Reply with OK",
|
||||
};
|
||||
const refused = await mcp.callTool({ name: "create_agent", arguments: args });
|
||||
expect(refused.isError).toBe(true);
|
||||
expect.soft(refused.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Pass an explicit mode in settings.modeId"),
|
||||
}),
|
||||
]);
|
||||
expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1);
|
||||
|
||||
const inspected = await mcp.callTool({
|
||||
name: "inspect_provider",
|
||||
arguments: { provider: "codex" },
|
||||
});
|
||||
expect(inspected.isError).not.toBe(true);
|
||||
expect(inspected.structuredContent).toMatchObject({
|
||||
modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]),
|
||||
});
|
||||
const created = await mcp.callTool({
|
||||
name: "create_agent",
|
||||
arguments: { ...args, settings: { modeId: "auto" } },
|
||||
});
|
||||
expect(created.isError).not.toBe(true);
|
||||
expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" });
|
||||
const childId = (created.structuredContent as { agentId: string }).agentId;
|
||||
expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto");
|
||||
expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle");
|
||||
} finally {
|
||||
await mcp.close();
|
||||
await ctx.cleanup();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
@@ -1623,13 +1623,26 @@ it.each([1, 4])(
|
||||
async (version) => {
|
||||
const paseoHome = createTemporaryHome();
|
||||
const old = await leaveWorkerWithTerminal(paseoHome, version);
|
||||
const [record] = await workerRecords(paseoHome);
|
||||
const program = record!.terminals.find((entry) => entry.id === old.terminalId)!;
|
||||
const inspector = createTerminalWorkerProcessInspector();
|
||||
|
||||
manager = await startRealWorkerManager({ paseoHome });
|
||||
|
||||
await waitForCondition(
|
||||
() => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid),
|
||||
10000,
|
||||
);
|
||||
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
|
||||
// Startup launches a replacement worker after cleanup. Either old PID may already
|
||||
// belong to another process, so verify that the recorded birth identities are gone.
|
||||
for (const original of [{ pid: record!.pid, ...record!.identity }, program]) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
!isProcessAlive(original.pid) ||
|
||||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid))
|
||||
.status === "unrelated",
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
|
||||
const workers = await workerRecords(paseoHome);
|
||||
expect(workers).toHaveLength(1);
|
||||
|
||||
@@ -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: "Build", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Parallel agents", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function viewPhones(page: Page) {
|
||||
@@ -59,3 +59,42 @@ 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");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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 {
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
ExternalLink,
|
||||
Gem,
|
||||
GitFork,
|
||||
LayoutPanelLeft,
|
||||
GitPullRequest,
|
||||
Laptop,
|
||||
Lock,
|
||||
Merge,
|
||||
@@ -19,16 +21,17 @@ 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
|
||||
@@ -86,7 +89,14 @@ import {
|
||||
PiIcon,
|
||||
} from "~/components/agent-icons";
|
||||
import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
|
||||
import { MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup";
|
||||
import {
|
||||
MobileChat,
|
||||
MobileDiff,
|
||||
MobileSidebar,
|
||||
PhoneFrame,
|
||||
type ToolCall,
|
||||
ToolCallRow,
|
||||
} from "~/components/mockup";
|
||||
import { FAQItem } from "~/components/faq-item";
|
||||
import { SiteFooter } from "~/components/site-footer";
|
||||
import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
|
||||
@@ -132,8 +142,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<SocialProofWall />
|
||||
<MultiProviderSection />
|
||||
<TurnkeySection />
|
||||
<AutomationSection />
|
||||
<ExtensibleSection />
|
||||
<AutomationSection />
|
||||
<SponsorsSection placement="page" />
|
||||
<SponsorSection />
|
||||
<PhilosophySection />
|
||||
@@ -703,41 +713,50 @@ 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">
|
||||
<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>
|
||||
<UserMessage>{request}</UserMessage>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpAgentCall({ issue, provider }: { issue: string; provider: string }) {
|
||||
function UserMessage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
@@ -842,17 +861,16 @@ function ExtensibleSection() {
|
||||
title={t("landing.plugins.title")}
|
||||
description={t("landing.plugins.description")}
|
||||
>
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -872,6 +890,156 @@ 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 };
|
||||
@@ -899,12 +1067,6 @@ 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" },
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
AudioLines,
|
||||
BookOpen,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
Copy,
|
||||
FilePenLine,
|
||||
Eye,
|
||||
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 } from "./icons";
|
||||
import { CodexIcon, PaseoMark } from "./icons";
|
||||
|
||||
// Icons passed as props are hoisted so they are not rebuilt on every render.
|
||||
const MODEL_ICON = <CodexIcon size={13} />;
|
||||
@@ -94,70 +94,62 @@ export function AgentTranscript() {
|
||||
);
|
||||
}
|
||||
|
||||
interface PluginToolCall {
|
||||
kind: "read" | "shell" | "write";
|
||||
label: string;
|
||||
detail: string;
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
|
||||
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_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_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 PluginToolIcon({ kind }: { kind: PluginToolCall["kind"] }) {
|
||||
if (kind === "read") return <BookOpen size={14} />;
|
||||
if (kind === "write") return <FilePenLine size={14} />;
|
||||
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} />;
|
||||
return <SquareTerminal size={14} />;
|
||||
}
|
||||
|
||||
function PluginToolRow({ call }: { call: PluginToolCall }) {
|
||||
/**
|
||||
* 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 }) {
|
||||
return (
|
||||
<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}
|
||||
<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} />
|
||||
</span>
|
||||
<span className="shrink-0">{toolCallLabel(call)}</span>
|
||||
<span className="min-w-0 truncate">{call.detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginToolGroup({ calls }: { calls: PluginToolCall[] }) {
|
||||
function PluginToolGroup({ calls }: { calls: ToolCall[] }) {
|
||||
return (
|
||||
<div>
|
||||
{calls.map((call) => (
|
||||
<PluginToolRow key={`${call.label}:${call.detail}`} call={call} />
|
||||
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -375,19 +375,18 @@
|
||||
"otherHostComment": "# Target another host"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Extend it with plugins",
|
||||
"description": "Add new panels and functionality that work in desktop, web and mobile",
|
||||
"lead": "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",
|
||||
"browse": "Browse plugins",
|
||||
"docs": "Plugin docs",
|
||||
"license": "Paseo is Apache 2.0.",
|
||||
"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"
|
||||
"fork": "Fork the repo"
|
||||
},
|
||||
"philosophy": {
|
||||
"title": "Philosophy",
|
||||
@@ -447,10 +446,10 @@
|
||||
},
|
||||
"mockup": {
|
||||
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
|
||||
"build": "Build",
|
||||
"review": "Review",
|
||||
"ship": "Ship",
|
||||
"extend": "Extend",
|
||||
"build": "Parallel agents",
|
||||
"review": "Code review",
|
||||
"ship": "Pull requests",
|
||||
"extend": "Plugins",
|
||||
"app": {
|
||||
"newWorkspace": "New workspace",
|
||||
"history": "History",
|
||||
|
||||
@@ -375,19 +375,18 @@
|
||||
"otherHostComment": "# 指定另一台主机"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "用插件扩展",
|
||||
"description": "添加新的面板和功能,在桌面端、网页端和移动端都能使用",
|
||||
"lead": "插件",
|
||||
"title": "让 Paseo 修改 Paseo",
|
||||
"description": "Paseo 里的 Agent 可以修改整个界面,无需重启,在移动端也一样。",
|
||||
"request": "在侧边栏加一个面板,列出等待我审查的 PR。",
|
||||
"plan": "我会把它做成本地插件并安装。",
|
||||
"done": "审查队列已出现在你的侧边栏。",
|
||||
"panelTitle": "审查队列",
|
||||
"libraryTitle": "400 多个社区插件",
|
||||
"libraryDescription": "开发者用的是和你的 Agent 相同的插件 API",
|
||||
"browse": "浏览插件",
|
||||
"docs": "插件文档",
|
||||
"license": "Paseo 采用 Apache 2.0 许可证。",
|
||||
"fork": "Fork 仓库",
|
||||
"uiTitle": "界面",
|
||||
"uiDescription": "页面、工作区面板、侧边栏项目、斜杠命令和主题",
|
||||
"lifecycleTitle": "Agent 生命周期",
|
||||
"lifecycleDescription": "修改配置和 MCP 服务器、响应权限请求、发送后续消息",
|
||||
"providersTitle": "Provider",
|
||||
"providersDescription": "把编程 Agent 添加为 Provider,用法与内置 Provider 相同"
|
||||
"fork": "Fork 仓库"
|
||||
},
|
||||
"philosophy": {
|
||||
"title": "理念",
|
||||
@@ -447,10 +446,10 @@
|
||||
},
|
||||
"mockup": {
|
||||
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
|
||||
"build": "构建",
|
||||
"review": "审查",
|
||||
"ship": "发布",
|
||||
"extend": "扩展",
|
||||
"build": "并行 Agent",
|
||||
"review": "代码审查",
|
||||
"ship": "Pull Request",
|
||||
"extend": "插件",
|
||||
"app": {
|
||||
"newWorkspace": "新建工作区",
|
||||
"history": "历史",
|
||||
|
||||
Reference in New Issue
Block a user