Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -43,6 +43,22 @@ In any worktree-style or portless setup, never assume default ports.
|
|||||||
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||||
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||||
|
|
||||||
|
### Desktop macOS compositor watchdog
|
||||||
|
|
||||||
|
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||||
|
source that drives frame production — stuck on a stale display. The compositor
|
||||||
|
then stops producing frames and the window looks frozen: unresponsive to clicks
|
||||||
|
and keys even though the renderer and every process stay alive. It self-recovers
|
||||||
|
after a few minutes, which is too long for a foreground app.
|
||||||
|
|
||||||
|
`setupDarwinCompositorWatchdog`
|
||||||
|
(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against
|
||||||
|
this. It polls the renderer for frame production every couple of seconds and,
|
||||||
|
after a sustained stall while the window is visible and unlocked, restarts the
|
||||||
|
GPU process so Chromium rebuilds the display link. The probe is skipped while
|
||||||
|
the screen is locked or the window is hidden or minimized, since a window
|
||||||
|
legitimately stops producing frames then.
|
||||||
|
|
||||||
### Daemon logs
|
### Daemon logs
|
||||||
|
|
||||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
|
|||||||
|
|
||||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||||
|
|
||||||
|
Pi direct embeds Pi's SDK through the `@earendil-works/pi-*` packages. Keep those dependencies in sync with the Pi package-manager behavior expected by current user installs: Pi 0.75+ loads user-scoped npm packages from `~/.pi/agent/npm/`, while older `@mariozechner/pi-coding-agent` releases looked in npm's global package root and can miss or load stale extensions.
|
||||||
|
|
||||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ The namespace reads left to right:
|
|||||||
|
|
||||||
- Domain: `checkout`
|
- Domain: `checkout`
|
||||||
- Provider or subsystem: `github`
|
- Provider or subsystem: `github`
|
||||||
- Operation: `set_auto_merge`
|
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
|
||||||
- Direction: `request` or `response`
|
- Direction: `request` or `response`
|
||||||
|
|
||||||
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
|
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-sRHRfPuhsoOx+wtEb+VYfs29wz3BdKu6vDYhaHr7LQE=
|
sha256-t6vqegESlQGlbkOfjpjOHbf6ufiDeBl/ApFtQHvOeLc=
|
||||||
|
|||||||
Generated
+567
-1892
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
import { type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for outbound WebSocket "session" frames of a given inner message type
|
||||||
|
* and accumulates them. The returned array is populated in-place as frames arrive.
|
||||||
|
*/
|
||||||
|
export function captureWsSessionFrames<T extends Record<string, unknown>>(
|
||||||
|
page: Page,
|
||||||
|
messageType: string,
|
||||||
|
extract: (inner: Record<string, unknown>) => T,
|
||||||
|
): T[] {
|
||||||
|
const captured: T[] = [];
|
||||||
|
page.on("websocket", (ws) => {
|
||||||
|
ws.on("framesent", (frame) => {
|
||||||
|
const raw = frame.payload;
|
||||||
|
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||||
|
try {
|
||||||
|
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
|
||||||
|
if (outer.type === "session" && outer.message?.type === messageType) {
|
||||||
|
captured.push(extract(outer.message));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore non-JSON and binary frames.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameModalInput(page: Page, testIdPrefix: string) {
|
||||||
|
return page.getByTestId(`${testIdPrefix}-input`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameModalSubmit(page: Page, testIdPrefix: string) {
|
||||||
|
return page.getByTestId(`${testIdPrefix}-submit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameModalError(page: Page, testIdPrefix: string) {
|
||||||
|
return page.getByTestId(`${testIdPrefix}-error`);
|
||||||
|
}
|
||||||
@@ -168,7 +168,7 @@ export async function expectGeneralContent(page: Page): Promise<void> {
|
|||||||
|
|
||||||
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveCount(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickEditHostLabel(page: Page): Promise<void> {
|
export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||||
@@ -176,9 +176,9 @@ export async function clickEditHostLabel(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
|
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
|
||||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible();
|
||||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
|
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel);
|
||||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { test, expect, type Page } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||||
|
import { captureWsSessionFrames } from "./helpers/rename";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceRowTestId(workspaceId: string): string {
|
||||||
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceRenameModalTestId(workspaceId: string, suffix: string): string {
|
||||||
|
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProjectViaDaemon(
|
||||||
|
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
||||||
|
const result = await client.openProject(cwd);
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(result.workspace.id),
|
||||||
|
name: result.workspace.name,
|
||||||
|
workspaceDirectory: result.workspace.workspaceDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRenameModal(page: Page, workspaceId: string) {
|
||||||
|
const serverId = getServerId();
|
||||||
|
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.hover();
|
||||||
|
|
||||||
|
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
|
||||||
|
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||||
|
await kebab.click();
|
||||||
|
|
||||||
|
const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`);
|
||||||
|
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||||
|
await renameItem.click();
|
||||||
|
|
||||||
|
const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input"));
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Sidebar workspace rename", () => {
|
||||||
|
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-rename-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
expect(workspace.name).toBe("main");
|
||||||
|
|
||||||
|
const renameRequests = captureWsSessionFrames(
|
||||||
|
page,
|
||||||
|
"checkout.rename_branch.request",
|
||||||
|
(inner) => ({
|
||||||
|
branch: String(inner.branch ?? ""),
|
||||||
|
cwd: String(inner.cwd ?? ""),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = await openRenameModal(page, workspace.id);
|
||||||
|
await expect(input).toHaveValue("main");
|
||||||
|
await input.fill("Feature Rename 2");
|
||||||
|
|
||||||
|
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
||||||
|
|
||||||
|
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText(
|
||||||
|
"feature-rename-2",
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(renameRequests.length).toBeGreaterThan(0);
|
||||||
|
expect(renameRequests.at(-1)).toEqual({
|
||||||
|
branch: "feature-rename-2",
|
||||||
|
cwd: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentBranchOnDisk = execSync("git branch --show-current", {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
expect(currentBranchOnDisk).toBe("feature-rename-2");
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
const input = await openRenameModal(page, workspace.id);
|
||||||
|
await expect(input).toHaveValue("main");
|
||||||
|
|
||||||
|
await input.fill("taken");
|
||||||
|
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
||||||
|
|
||||||
|
const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error"));
|
||||||
|
await expect(errorNode).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(errorNode).toContainText(/already exists|branch/i);
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main");
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { test, expect, type Page } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
connectArchiveTabDaemonClient,
|
||||||
|
createIdleAgent,
|
||||||
|
expectWorkspaceTabVisible,
|
||||||
|
} from "./helpers/archive-tab";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||||
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
|
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
|
||||||
|
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
|
||||||
|
await page.waitForURL(
|
||||||
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Workspace agent tab rename", () => {
|
||||||
|
test("right-click rename sends update_agent_request and updates the tab label", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
const client = await connectArchiveTabDaemonClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-agent-rename-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
|
||||||
|
const agent = await createIdleAgent(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
title: initialTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({
|
||||||
|
agentId: String(inner.agentId ?? ""),
|
||||||
|
name: String(inner.name ?? ""),
|
||||||
|
requestId: String(inner.requestId ?? ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await openAgentInWorkspace(page, agent);
|
||||||
|
|
||||||
|
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||||
|
await expect(tab).toContainText(initialTitle, { timeout: 15_000 });
|
||||||
|
|
||||||
|
await tab.click({ button: "right" });
|
||||||
|
await expect(page.getByTestId(`workspace-tab-context-agent_${agent.id}`)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
const renameItem = page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`);
|
||||||
|
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||||
|
await renameItem.click();
|
||||||
|
|
||||||
|
const modalPrefix = `workspace-tab-rename-modal-agent-${agent.id}`;
|
||||||
|
const input = renameModalInput(page, modalPrefix);
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(input).toHaveValue(initialTitle);
|
||||||
|
|
||||||
|
const renamed = "My Renamed Agent";
|
||||||
|
await input.fill(renamed);
|
||||||
|
await renameModalSubmit(page, modalPrefix).click();
|
||||||
|
|
||||||
|
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
await expect(tab).toContainText(renamed, { timeout: 15_000 });
|
||||||
|
|
||||||
|
expect(updateFrames.length).toBeGreaterThan(0);
|
||||||
|
const lastFrame = updateFrames.at(-1)!;
|
||||||
|
expect(lastFrame.agentId).toBe(agent.id);
|
||||||
|
expect(lastFrame.name).toBe(renamed);
|
||||||
|
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf";
|
||||||
|
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||||
|
|
||||||
|
test.describe("Workspace terminal tab rename", () => {
|
||||||
|
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const client = await connectTerminalClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-terminal-rename-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeded = await client.openProject(repo.path);
|
||||||
|
if (!seeded.workspace) {
|
||||||
|
throw new Error(seeded.error ?? "Failed to seed workspace");
|
||||||
|
}
|
||||||
|
const workspaceId = seeded.workspace.id;
|
||||||
|
|
||||||
|
const created = await client.createTerminal(repo.path);
|
||||||
|
if (!created.terminal) {
|
||||||
|
throw new Error(created.error ?? "Failed to create terminal");
|
||||||
|
}
|
||||||
|
const terminalId = created.terminal.id;
|
||||||
|
|
||||||
|
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
|
||||||
|
terminalId: String(inner.terminalId ?? ""),
|
||||||
|
title: String(inner.title ?? ""),
|
||||||
|
requestId: String(inner.requestId ?? ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||||
|
|
||||||
|
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||||
|
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
await tab.click({ button: "right" });
|
||||||
|
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
||||||
|
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||||
|
await renameItem.click();
|
||||||
|
|
||||||
|
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||||
|
const input = renameModalInput(page, modalPrefix);
|
||||||
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await input.fill("My Renamed Terminal");
|
||||||
|
await renameModalSubmit(page, modalPrefix).click();
|
||||||
|
|
||||||
|
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||||
|
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
|
||||||
|
|
||||||
|
expect(renameFrames.length).toBeGreaterThan(0);
|
||||||
|
const lastFrame = renameFrames.at(-1)!;
|
||||||
|
expect(lastFrame.terminalId).toBe(terminalId);
|
||||||
|
expect(lastFrame.title).toBe("My Renamed Terminal");
|
||||||
|
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
AssistantInlineCodePathLink,
|
AssistantInlineCodePathLink,
|
||||||
AssistantInlinePathLink,
|
|
||||||
AssistantMarkdownCodeLink,
|
AssistantMarkdownCodeLink,
|
||||||
AssistantMarkdownLink,
|
AssistantMarkdownLink,
|
||||||
} from "./link";
|
} from "./link";
|
||||||
@@ -9,5 +8,9 @@ export {
|
|||||||
normalizeInlinePathTarget,
|
normalizeInlinePathTarget,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./parse";
|
} from "./parse";
|
||||||
|
export {
|
||||||
|
AssistantFileLinkResolverProvider,
|
||||||
|
type AssistantFileLinkResolverProviderProps,
|
||||||
|
} from "./provider";
|
||||||
export type { AssistantFileLinkSource } from "./resolver";
|
export type { AssistantFileLinkSource } from "./resolver";
|
||||||
export { useAssistantFileLinkResolver } from "./use-resolver";
|
export { useAssistantFileLinkActions } from "./use-file-link";
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type CSSProperties,
|
|
||||||
type ReactNode,
|
|
||||||
type MouseEvent,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
@@ -16,128 +9,66 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||||
import type { AssistantFileLinkSource } from "./resolver";
|
import type { AssistantFileLinkSource } from "./resolver";
|
||||||
|
import { useFileLink } from "./use-file-link";
|
||||||
interface AssistantInlinePathLinkProps {
|
|
||||||
content: string;
|
|
||||||
parsed: InlinePathTarget;
|
|
||||||
onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
style: StyleProp<TextStyle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssistantInlinePathLink({
|
|
||||||
content,
|
|
||||||
parsed,
|
|
||||||
onPress,
|
|
||||||
workspaceRoot,
|
|
||||||
style,
|
|
||||||
}: AssistantInlinePathLinkProps) {
|
|
||||||
const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]);
|
|
||||||
const handleAnchorClickCapture = useCallback(
|
|
||||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!isModifiedOpenEvent(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.stopPropagation();
|
|
||||||
onPress(parsed, "side");
|
|
||||||
},
|
|
||||||
[onPress, parsed],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isNative) {
|
|
||||||
return (
|
|
||||||
<FileLinkHoverTooltip filePath={formatInlinePathTargetForTooltip(parsed, workspaceRoot)}>
|
|
||||||
<a
|
|
||||||
href={parsed.path}
|
|
||||||
onClickCapture={handleAnchorClickCapture}
|
|
||||||
onAuxClickCapture={preventAnchorNavigation}
|
|
||||||
style={LINK_ANCHOR_STYLE}
|
|
||||||
>
|
|
||||||
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
|
|
||||||
{content}
|
|
||||||
</Text>
|
|
||||||
</a>
|
|
||||||
</FileLinkHoverTooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
|
|
||||||
{content}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssistantMarkdownLinkProps {
|
interface AssistantMarkdownLinkProps {
|
||||||
source: AssistantFileLinkSource;
|
source: AssistantFileLinkSource;
|
||||||
style: StyleProp<TextStyle>;
|
style: StyleProp<TextStyle>;
|
||||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
|
||||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantMarkdownLink({
|
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
|
||||||
source,
|
|
||||||
style,
|
|
||||||
onPress,
|
|
||||||
onPrefetch,
|
|
||||||
workspaceRoot,
|
|
||||||
children,
|
|
||||||
}: AssistantMarkdownLinkProps) {
|
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const href = source.href;
|
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
|
||||||
const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]);
|
const { configRef } = useAssistantFileLinkResolverContext();
|
||||||
const handleAnchorClickCapture = useCallback(
|
const workspaceRoot = configRef.current.workspaceRoot;
|
||||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
const tooltipPath = useMemo(
|
||||||
event.preventDefault();
|
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||||
if (!isModifiedOpenEvent(event)) {
|
[target, workspaceRoot],
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.stopPropagation();
|
|
||||||
onPress(source, "side");
|
|
||||||
},
|
|
||||||
[onPress, source],
|
|
||||||
);
|
);
|
||||||
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
|
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
const handleHoverIn = useCallback(() => {
|
event.preventDefault();
|
||||||
|
if (!isModifiedOpenEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
onAuxPress();
|
||||||
|
});
|
||||||
|
const handleHoverIn = useStableEvent(() => {
|
||||||
setHovered(true);
|
setHovered(true);
|
||||||
handlePrefetch();
|
onHoverIn();
|
||||||
}, [handlePrefetch]);
|
});
|
||||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
const handleHoverOut = useStableEvent(() => setHovered(false));
|
||||||
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||||
[style, hovered],
|
[style, hovered],
|
||||||
);
|
);
|
||||||
const tooltipFilePath = useMemo(
|
|
||||||
() => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot),
|
|
||||||
[source.href, workspaceRoot],
|
|
||||||
);
|
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
return (
|
return (
|
||||||
<Text accessibilityRole="link" onPress={handlePress} style={style}>
|
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||||
{children}
|
<Text accessibilityRole="link" onPress={onPress} style={style}>
|
||||||
</Text>
|
{children}
|
||||||
|
</Text>
|
||||||
|
</FileLinkHoverTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchor = (
|
const anchor = (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={source.href}
|
||||||
onClickCapture={handleAnchorClickCapture}
|
onClickCapture={handleAnchorClickCapture}
|
||||||
onAuxClickCapture={preventAnchorNavigation}
|
onAuxClickCapture={preventAnchorNavigation}
|
||||||
style={LINK_ANCHOR_STYLE}
|
style={LINK_ANCHOR_STYLE}
|
||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
onPress={handlePress}
|
onPress={onPress}
|
||||||
onFocus={handlePrefetch}
|
|
||||||
onHoverIn={handleHoverIn}
|
onHoverIn={handleHoverIn}
|
||||||
onHoverOut={handleHoverOut}
|
onHoverOut={handleHoverOut}
|
||||||
>
|
>
|
||||||
@@ -146,10 +77,7 @@ export function AssistantMarkdownLink({
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tooltipFilePath) {
|
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
|
||||||
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
|
|
||||||
}
|
|
||||||
return anchor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssistantMarkdownCodeLinkProps {
|
interface AssistantMarkdownCodeLinkProps {
|
||||||
@@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps {
|
|||||||
inheritedStyles: TextStyle;
|
inheritedStyles: TextStyle;
|
||||||
codeInlineStyle: TextStyle;
|
codeInlineStyle: TextStyle;
|
||||||
linkStyle: TextStyle;
|
linkStyle: TextStyle;
|
||||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
|
||||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({
|
|||||||
inheritedStyles,
|
inheritedStyles,
|
||||||
codeInlineStyle,
|
codeInlineStyle,
|
||||||
linkStyle,
|
linkStyle,
|
||||||
onPress,
|
|
||||||
onPrefetch,
|
|
||||||
workspaceRoot,
|
|
||||||
children,
|
children,
|
||||||
}: AssistantMarkdownCodeLinkProps) {
|
}: AssistantMarkdownCodeLinkProps) {
|
||||||
const style = useMemo(
|
const style = useMemo(
|
||||||
@@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({
|
|||||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<AssistantMarkdownLink
|
<AssistantMarkdownLink source={source} style={style}>
|
||||||
source={source}
|
|
||||||
style={style}
|
|
||||||
onPress={onPress}
|
|
||||||
onPrefetch={onPrefetch}
|
|
||||||
workspaceRoot={workspaceRoot}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</AssistantMarkdownLink>
|
</AssistantMarkdownLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssistantInlineCodePathLinkProps {
|
|
||||||
content: string;
|
|
||||||
inheritedStyles: TextStyle;
|
|
||||||
codeInlineStyle: TextStyle;
|
|
||||||
linkStyle: TextStyle;
|
|
||||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
|
||||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssistantInlineCodePathLink({
|
|
||||||
content,
|
|
||||||
inheritedStyles,
|
|
||||||
codeInlineStyle,
|
|
||||||
linkStyle,
|
|
||||||
onPress,
|
|
||||||
onPrefetch,
|
|
||||||
workspaceRoot,
|
|
||||||
}: AssistantInlineCodePathLinkProps) {
|
|
||||||
const source = useMemo<AssistantFileLinkSource>(
|
|
||||||
() => ({
|
|
||||||
href: content,
|
|
||||||
text: content,
|
|
||||||
sourceType: "inline-code",
|
|
||||||
}),
|
|
||||||
[content],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AssistantMarkdownCodeLink
|
|
||||||
source={source}
|
|
||||||
inheritedStyles={inheritedStyles}
|
|
||||||
codeInlineStyle={codeInlineStyle}
|
|
||||||
linkStyle={linkStyle}
|
|
||||||
onPress={onPress}
|
|
||||||
onPrefetch={onPrefetch}
|
|
||||||
workspaceRoot={workspaceRoot}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</AssistantMarkdownCodeLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarkdownLinkTooltipFilePath(
|
|
||||||
href: string,
|
|
||||||
workspaceRoot: string | undefined,
|
|
||||||
): string | null {
|
|
||||||
const classification = classifyAssistantFileLink(href, { workspaceRoot });
|
|
||||||
if (classification?.kind !== "directFile") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return formatInlinePathTargetForTooltip(classification.target, workspaceRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInlinePathTargetForTooltip(
|
function formatInlinePathTargetForTooltip(
|
||||||
target: InlinePathTarget,
|
target: { path: string; lineStart?: number; lineEnd?: number },
|
||||||
workspaceRoot: string | undefined,
|
workspaceRoot: string | undefined,
|
||||||
): string {
|
): string {
|
||||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||||
@@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und
|
|||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AssistantInlineCodePathLinkProps {
|
||||||
|
content: string;
|
||||||
|
inheritedStyles: TextStyle;
|
||||||
|
codeInlineStyle: TextStyle;
|
||||||
|
linkStyle: TextStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssistantInlineCodePathLink({
|
||||||
|
content,
|
||||||
|
inheritedStyles,
|
||||||
|
codeInlineStyle,
|
||||||
|
linkStyle,
|
||||||
|
}: AssistantInlineCodePathLinkProps) {
|
||||||
|
const source = useMemo<AssistantFileLinkSource>(
|
||||||
|
() => ({
|
||||||
|
href: content,
|
||||||
|
text: content,
|
||||||
|
sourceType: "inline-code",
|
||||||
|
}),
|
||||||
|
[content],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantMarkdownCodeLink
|
||||||
|
source={source}
|
||||||
|
inheritedStyles={inheritedStyles}
|
||||||
|
codeInlineStyle={codeInlineStyle}
|
||||||
|
linkStyle={linkStyle}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</AssistantMarkdownCodeLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
||||||
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
|
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
|
||||||
// the tooltip wrapper from breaking inline link flow.
|
// the tooltip wrapper from breaking inline link flow.
|
||||||
@@ -284,7 +180,13 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
|
|||||||
|
|
||||||
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
|
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
|
||||||
|
|
||||||
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) {
|
function FileLinkHoverTooltip({
|
||||||
|
filePath,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
filePath: string | null;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
if (!isWeb) {
|
if (!isWeb) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
@@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
|
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
{filePath ? (
|
||||||
<View style={styles.tooltipBody}>
|
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||||
<Text selectable={false} style={styles.tooltipPath}>
|
<View style={styles.tooltipBody}>
|
||||||
{filePath}
|
<Text selectable={false} style={styles.tooltipPath}>
|
||||||
</Text>
|
{filePath}
|
||||||
<View style={styles.tooltipHintRow}>
|
|
||||||
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
|
|
||||||
<Text selectable={false} style={styles.tooltipHintText}>
|
|
||||||
click for side pane
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<View style={styles.tooltipHintRow}>
|
||||||
|
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
|
||||||
|
<Text selectable={false} style={styles.tooltipHintText}>
|
||||||
|
click for side pane
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
) : null}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
type MutableRefObject,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import React from "react";
|
||||||
|
import type { ToastApi } from "@/components/toast-host";
|
||||||
|
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||||
|
import type { InlinePathTarget } from "./parse";
|
||||||
|
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
|
||||||
|
|
||||||
|
export interface AssistantFileLinkDaemonClient {
|
||||||
|
getDirectorySuggestions: GetDirectorySuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkResolverConfig {
|
||||||
|
client?: AssistantFileLinkDaemonClient | null;
|
||||||
|
serverId?: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||||
|
toast?: ToastApi | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkResolverProviderProps extends AssistantFileLinkResolverConfig {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkResolverContextValue {
|
||||||
|
configRef: MutableRefObject<AssistantFileLinkResolverConfig>;
|
||||||
|
getDirectorySuggestions: GetDirectorySuggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssistantFileLinkResolverContext =
|
||||||
|
createContext<AssistantFileLinkResolverContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AssistantFileLinkResolverProvider({
|
||||||
|
client,
|
||||||
|
serverId,
|
||||||
|
workspaceRoot,
|
||||||
|
onOpenWorkspaceFile,
|
||||||
|
toast,
|
||||||
|
children,
|
||||||
|
}: AssistantFileLinkResolverProviderProps) {
|
||||||
|
const configRef = useRef<AssistantFileLinkResolverConfig>({
|
||||||
|
client,
|
||||||
|
serverId,
|
||||||
|
workspaceRoot,
|
||||||
|
onOpenWorkspaceFile,
|
||||||
|
toast,
|
||||||
|
});
|
||||||
|
configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast };
|
||||||
|
|
||||||
|
const getDirectorySuggestions = useCallback<GetDirectorySuggestions>(async (input) => {
|
||||||
|
const activeClient = configRef.current.client;
|
||||||
|
if (!activeClient) {
|
||||||
|
return { entries: [], error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await activeClient.getDirectorySuggestions(input);
|
||||||
|
return { entries: result.entries, error: result.error };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AssistantFileLinkResolverContextValue>(
|
||||||
|
() => ({ configRef, getDirectorySuggestions }),
|
||||||
|
[getDirectorySuggestions],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantFileLinkResolverContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AssistantFileLinkResolverContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue {
|
||||||
|
const context = useContext(AssistantFileLinkResolverContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("AssistantFileLinkResolverProvider is required for assistant file links.");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { AssistantFileLinkContext };
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
createAssistantFileLinkResolver,
|
classifyForResolution,
|
||||||
|
fetchDaemonResolution,
|
||||||
getAssistantFileLinkToken,
|
getAssistantFileLinkToken,
|
||||||
|
UnresolvedFileLinkError,
|
||||||
type AssistantFileLinkContext,
|
type AssistantFileLinkContext,
|
||||||
type DirectorySuggestionEntry,
|
type DirectorySuggestionEntry,
|
||||||
type DirectorySuggestionResult,
|
type DirectorySuggestionResult,
|
||||||
} from "./resolver";
|
} from "./resolver";
|
||||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
|
||||||
import type { InlinePathTarget } from "./parse";
|
|
||||||
|
|
||||||
const CONTEXT: AssistantFileLinkContext = {
|
const CONTEXT: AssistantFileLinkContext = {
|
||||||
serverId: "server-1",
|
|
||||||
workspaceRoot: "/Users/test/project",
|
workspaceRoot: "/Users/test/project",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,554 +19,201 @@ function resolvedSuggestions(
|
|||||||
return { entries, error: null };
|
return { entries, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeferred<T>() {
|
function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
|
||||||
let resolve!: (value: T) => void;
|
getDirectorySuggestions: ReturnType<typeof vi.fn>;
|
||||||
let reject!: (error: unknown) => void;
|
searches: Array<{
|
||||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
query: string;
|
||||||
resolve = promiseResolve;
|
cwd: string;
|
||||||
reject = promiseReject;
|
matchMode: "suffix";
|
||||||
});
|
limit: number;
|
||||||
|
}>;
|
||||||
return { promise, resolve, reject };
|
} {
|
||||||
|
const searches: Array<{
|
||||||
|
query: string;
|
||||||
|
cwd: string;
|
||||||
|
matchMode: "suffix";
|
||||||
|
limit: number;
|
||||||
|
}> = [];
|
||||||
|
const getDirectorySuggestions = vi.fn(
|
||||||
|
async (input: {
|
||||||
|
query: string;
|
||||||
|
cwd: string;
|
||||||
|
includeFiles: true;
|
||||||
|
includeDirectories: false;
|
||||||
|
matchMode: "suffix";
|
||||||
|
limit: number;
|
||||||
|
}) => {
|
||||||
|
searches.push({
|
||||||
|
query: input.query,
|
||||||
|
cwd: input.cwd,
|
||||||
|
matchMode: input.matchMode,
|
||||||
|
limit: input.limit,
|
||||||
|
});
|
||||||
|
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { getDirectorySuggestions, searches };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DirectorySearch {
|
describe("classifyForResolution", () => {
|
||||||
query: string;
|
it("returns the directFile target synchronously", () => {
|
||||||
cwd: string;
|
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||||
includeFiles: true;
|
|
||||||
includeDirectories: false;
|
|
||||||
matchMode: "suffix";
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpenedFile {
|
expect(result).toEqual({
|
||||||
target: InlinePathTarget;
|
kind: "resolved",
|
||||||
disposition: OpenFileDisposition;
|
value: {
|
||||||
}
|
kind: "file",
|
||||||
|
target: {
|
||||||
class FakeWorkspaceFiles {
|
raw: "src/components/message.tsx#L33",
|
||||||
readonly searches: DirectorySearch[] = [];
|
path: "/Users/test/project/src/components/message.tsx",
|
||||||
readonly openedFiles: OpenedFile[] = [];
|
lineStart: 33,
|
||||||
readonly unresolvedTokens: string[] = [];
|
lineEnd: undefined,
|
||||||
|
},
|
||||||
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
|
},
|
||||||
|
|
||||||
createResolver() {
|
|
||||||
return createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: this.getDirectorySuggestions,
|
|
||||||
openWorkspaceFile: this.openWorkspaceFile,
|
|
||||||
openExternalUrl: async () => {},
|
|
||||||
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
private getDirectorySuggestions = async (
|
it("preserves line ranges on direct workspace files", () => {
|
||||||
search: DirectorySearch,
|
const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT);
|
||||||
): Promise<DirectorySuggestionResult> => {
|
|
||||||
this.searches.push(search);
|
|
||||||
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
|
|
||||||
};
|
|
||||||
|
|
||||||
private openWorkspaceFile = (
|
expect(result).toEqual({
|
||||||
target: InlinePathTarget,
|
kind: "resolved",
|
||||||
disposition: OpenFileDisposition,
|
value: {
|
||||||
): void => {
|
kind: "file",
|
||||||
this.openedFiles.push({ target, disposition });
|
target: {
|
||||||
};
|
raw: "src/components/message.tsx:33-40",
|
||||||
|
path: "/Users/test/project/src/components/message.tsx",
|
||||||
|
lineStart: 33,
|
||||||
|
lineEnd: 40,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
private onUnresolvedFileCandidate = (token: string): void => {
|
it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => {
|
||||||
this.unresolvedTokens.push(token);
|
const result = classifyForResolution(
|
||||||
};
|
{ href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" },
|
||||||
}
|
CONTEXT,
|
||||||
|
|
||||||
describe("assistant file link resolver", () => {
|
|
||||||
it("dedupes in-flight prefetches and serves the click from cache", async () => {
|
|
||||||
const suggestions = vi.fn(async () =>
|
|
||||||
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
|
|
||||||
);
|
);
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const openExternalUrl = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: suggestions,
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl,
|
|
||||||
});
|
|
||||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
|
||||||
|
|
||||||
await Promise.all([
|
expect(result).toEqual({
|
||||||
resolver.prefetch({ context: CONTEXT, source }),
|
kind: "needsLookup",
|
||||||
resolver.prefetch({ context: CONTEXT, source }),
|
ambiguousQuery: "file.ts",
|
||||||
]);
|
token: "file.ts:12",
|
||||||
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
target: {
|
||||||
|
raw: "file.ts:12",
|
||||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
path: "/Users/test/project/file.ts",
|
||||||
expect(suggestions).toHaveBeenCalledWith({
|
lineStart: 12,
|
||||||
query: "dumm.md",
|
|
||||||
cwd: "/Users/test/project",
|
|
||||||
includeFiles: true,
|
|
||||||
includeDirectories: false,
|
|
||||||
matchMode: "suffix",
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
raw: "dumm.md",
|
|
||||||
path: "/Users/test/project/src/dumm.md",
|
|
||||||
lineStart: undefined,
|
|
||||||
lineEnd: undefined,
|
lineEnd: undefined,
|
||||||
},
|
},
|
||||||
"main",
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit external URLs external", () => {
|
||||||
|
const result = classifyForResolution({ href: "http://dumm.md", text: "dumm.md" }, CONTEXT);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
kind: "resolved",
|
||||||
|
value: { kind: "external", url: "http://dumm.md" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps auto-linkified normal domains external", () => {
|
||||||
|
const result = classifyForResolution(
|
||||||
|
{ href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||||
|
CONTEXT,
|
||||||
);
|
);
|
||||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
|
||||||
expect(result.opened).toBe(true);
|
expect(result).toEqual({
|
||||||
|
kind: "resolved",
|
||||||
|
value: { kind: "external", url: "http://google.com" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("click consumes an in-flight hover resolution", async () => {
|
it("returns ignored for non-file-looking content", () => {
|
||||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
const result = classifyForResolution({ href: "" }, CONTEXT);
|
||||||
const suggestions = vi.fn(() => deferred.promise);
|
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: suggestions,
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
});
|
|
||||||
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
|
|
||||||
|
|
||||||
const prefetch = resolver.prefetch({ context: CONTEXT, source });
|
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
|
||||||
const opened = resolver.open({ context: CONTEXT, source, disposition: "main" });
|
|
||||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
|
||||||
|
|
||||||
await prefetch;
|
|
||||||
const result = await opened;
|
|
||||||
|
|
||||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
|
||||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
raw: "dumm.md",
|
|
||||||
path: "/Users/test/project/docs/dumm.md",
|
|
||||||
lineStart: undefined,
|
|
||||||
lineEnd: undefined,
|
|
||||||
},
|
|
||||||
"main",
|
|
||||||
);
|
|
||||||
expect(result.opened).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("retries a click after hover prefetch fails to query suggestions", async () => {
|
describe("fetchDaemonResolution", () => {
|
||||||
const suggestions = vi
|
it("resolves daemon suggestions into workspace file targets", async () => {
|
||||||
.fn()
|
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||||
.mockRejectedValueOnce(new Error("daemon unavailable"))
|
|
||||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const openExternalUrl = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: suggestions,
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl,
|
|
||||||
});
|
|
||||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
|
||||||
|
|
||||||
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
|
|
||||||
const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
|
||||||
|
|
||||||
expect(prefetchResult).toEqual({
|
|
||||||
kind: "unresolvedFileCandidate",
|
|
||||||
token: "dumm.md",
|
|
||||||
});
|
|
||||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
|
||||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
raw: "dumm.md",
|
|
||||||
path: "/Users/test/project/docs/dumm.md",
|
|
||||||
lineStart: undefined,
|
|
||||||
lineEnd: undefined,
|
|
||||||
},
|
|
||||||
"main",
|
|
||||||
);
|
|
||||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
|
||||||
expect(openResult.opened).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not cache unresolved candidates", async () => {
|
|
||||||
const suggestions = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(resolvedSuggestions([]))
|
|
||||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const openExternalUrl = vi.fn();
|
|
||||||
const onUnresolvedFileCandidate = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: suggestions,
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl,
|
|
||||||
onUnresolvedFileCandidate,
|
|
||||||
});
|
|
||||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
|
||||||
|
|
||||||
const first = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
|
||||||
const second = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
|
||||||
|
|
||||||
expect(first).toEqual({
|
|
||||||
kind: "unresolvedFileCandidate",
|
|
||||||
token: "dumm.md",
|
|
||||||
opened: false,
|
|
||||||
});
|
|
||||||
expect(second.opened).toBe(true);
|
|
||||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
|
||||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
raw: "dumm.md",
|
|
||||||
path: "/Users/test/project/docs/dumm.md",
|
|
||||||
lineStart: undefined,
|
|
||||||
lineEnd: undefined,
|
|
||||||
},
|
|
||||||
"main",
|
|
||||||
);
|
|
||||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
|
||||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keys cache entries by server, workspace, and token", async () => {
|
|
||||||
const suggestions = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
|
|
||||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
|
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: suggestions,
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
});
|
|
||||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
|
||||||
|
|
||||||
await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
|
||||||
await resolver.open({
|
|
||||||
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
|
|
||||||
source,
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
|
||||||
expect(openWorkspaceFile).toHaveBeenLastCalledWith(
|
|
||||||
{
|
|
||||||
raw: "dumm.md",
|
|
||||||
path: "/Users/test/other/two/dumm.md",
|
|
||||||
lineStart: undefined,
|
|
||||||
lineEnd: undefined,
|
|
||||||
},
|
|
||||||
"main",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not apply stale async results after the active context changes", async () => {
|
|
||||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
|
||||||
let isCurrent = true;
|
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: vi.fn(() => deferred.promise),
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
isCurrentContext: () => isCurrent,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opened = resolver.open({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
isCurrent = false;
|
|
||||||
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
|
|
||||||
const result = await opened;
|
|
||||||
|
|
||||||
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
|
||||||
expect(result.opened).toBe(false);
|
|
||||||
expect(result.kind).toBe("file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens direct workspace file links without querying suggestions", async () => {
|
|
||||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: suggestions,
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolver.open({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "src/components/message.tsx#L33" },
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(suggestions).not.toHaveBeenCalled();
|
|
||||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
raw: "src/components/message.tsx#L33",
|
|
||||||
path: "/Users/test/project/src/components/message.tsx",
|
|
||||||
lineStart: 33,
|
|
||||||
lineEnd: undefined,
|
|
||||||
},
|
|
||||||
"main",
|
|
||||||
);
|
|
||||||
expect(result.opened).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves direct workspace file line ranges", async () => {
|
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await resolver.open({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "src/components/message.tsx:33-40" },
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
raw: "src/components/message.tsx:33-40",
|
|
||||||
path: "/Users/test/project/src/components/message.tsx",
|
|
||||||
lineStart: 33,
|
|
||||||
lineEnd: 40,
|
|
||||||
},
|
|
||||||
"main",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens basename line refs when the daemon returns that exact filename", async () => {
|
|
||||||
const workspaceFiles = new FakeWorkspaceFiles({
|
|
||||||
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
|
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
|
||||||
});
|
});
|
||||||
const resolver = workspaceFiles.createResolver();
|
|
||||||
|
|
||||||
const result = await resolver.open({
|
const result = await fetchDaemonResolution({
|
||||||
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
|
ambiguousQuery: "file.ts",
|
||||||
source: {
|
token: "file.ts:12",
|
||||||
href: "file.ts:12",
|
target: {
|
||||||
text: "file.ts:12",
|
raw: "file.ts:12",
|
||||||
sourceType: "inline-code",
|
path: "/Users/test/project/file.ts",
|
||||||
|
lineStart: 12,
|
||||||
|
lineEnd: undefined,
|
||||||
},
|
},
|
||||||
disposition: "main",
|
workspaceRoot: "/Users/test/project",
|
||||||
|
getDirectorySuggestions,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(workspaceFiles.searches).toEqual([
|
expect(searches).toEqual([
|
||||||
{
|
{
|
||||||
query: "file.ts",
|
query: "file.ts",
|
||||||
cwd: "/Users/test/project",
|
cwd: "/Users/test/project",
|
||||||
includeFiles: true,
|
|
||||||
includeDirectories: false,
|
|
||||||
matchMode: "suffix",
|
matchMode: "suffix",
|
||||||
limit: 1,
|
limit: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(workspaceFiles.openedFiles).toEqual([
|
expect(result).toEqual({
|
||||||
{
|
raw: "file.ts:12",
|
||||||
|
path: "/Users/test/project/packages/app/src/file.ts",
|
||||||
|
lineStart: 12,
|
||||||
|
lineEnd: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a typed unresolved error when the daemon finds no match", async () => {
|
||||||
|
const { getDirectorySuggestions } = suggestionsFromMap({});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchDaemonResolution({
|
||||||
|
ambiguousQuery: "src/file.ts",
|
||||||
|
token: "src/file.ts",
|
||||||
target: {
|
target: {
|
||||||
raw: "file.ts:12",
|
raw: "src/file.ts",
|
||||||
path: "/Users/test/project/packages/app/src/file.ts",
|
path: "/Users/test/project/src/file.ts",
|
||||||
lineStart: 12,
|
lineStart: undefined,
|
||||||
lineEnd: undefined,
|
lineEnd: undefined,
|
||||||
},
|
},
|
||||||
disposition: "main",
|
workspaceRoot: "/Users/test/project",
|
||||||
},
|
getDirectorySuggestions,
|
||||||
]);
|
|
||||||
expect(result.opened).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => {
|
|
||||||
const workspaceFiles = new FakeWorkspaceFiles({});
|
|
||||||
const resolver = workspaceFiles.createResolver();
|
|
||||||
|
|
||||||
const result = await resolver.open({
|
|
||||||
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
|
|
||||||
source: {
|
|
||||||
href: "src/file.ts",
|
|
||||||
text: "src/file.ts",
|
|
||||||
sourceType: "inline-code",
|
|
||||||
},
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(workspaceFiles.searches).toEqual([
|
|
||||||
{
|
|
||||||
query: "src/file.ts",
|
|
||||||
cwd: "/Users/test/project",
|
|
||||||
includeFiles: true,
|
|
||||||
includeDirectories: false,
|
|
||||||
matchMode: "suffix",
|
|
||||||
limit: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(workspaceFiles.openedFiles).toEqual([]);
|
|
||||||
expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]);
|
|
||||||
expect(result).toEqual({
|
|
||||||
kind: "unresolvedFileCandidate",
|
|
||||||
token: "src/file.ts",
|
|
||||||
opened: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes side open disposition to workspace file links", async () => {
|
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await resolver.open({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "src/components/message.tsx#L33" },
|
|
||||||
disposition: "side",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(openWorkspaceFile).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
raw: "src/components/message.tsx#L33",
|
|
||||||
path: "/Users/test/project/src/components/message.tsx",
|
|
||||||
lineStart: 33,
|
|
||||||
lineEnd: undefined,
|
|
||||||
},
|
|
||||||
"side",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps explicit external URLs external", async () => {
|
|
||||||
const openExternalUrl = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
|
||||||
openWorkspaceFile: vi.fn(),
|
|
||||||
openExternalUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolver.open({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "http://dumm.md", text: "dumm.md" },
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
|
|
||||||
expect(result).toEqual({
|
|
||||||
kind: "external",
|
|
||||||
url: "http://dumm.md",
|
|
||||||
opened: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps auto-linkified normal domains external", async () => {
|
|
||||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
|
||||||
const openExternalUrl = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: suggestions,
|
|
||||||
openWorkspaceFile: vi.fn(),
|
|
||||||
openExternalUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolver.open({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(suggestions).not.toHaveBeenCalled();
|
|
||||||
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
|
|
||||||
expect(result).toEqual({
|
|
||||||
kind: "external",
|
|
||||||
url: "http://google.com",
|
|
||||||
opened: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps auto-linkified normal domain paths external", async () => {
|
|
||||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
|
||||||
const openExternalUrl = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: suggestions,
|
|
||||||
openWorkspaceFile: vi.fn(),
|
|
||||||
openExternalUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolver.open({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(suggestions).not.toHaveBeenCalled();
|
|
||||||
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
|
|
||||||
expect(result).toEqual({
|
|
||||||
kind: "external",
|
|
||||||
url: "http://openai.com/path",
|
|
||||||
opened: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not open unresolved linkified markdown filenames in the browser", async () => {
|
|
||||||
const openWorkspaceFile = vi.fn();
|
|
||||||
const openExternalUrl = vi.fn();
|
|
||||||
const onUnresolvedFileCandidate = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
|
||||||
openWorkspaceFile,
|
|
||||||
openExternalUrl,
|
|
||||||
onUnresolvedFileCandidate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const prefetchResult = await resolver.prefetch({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
|
||||||
});
|
|
||||||
const result = await resolver.open({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
|
||||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
|
||||||
expect(prefetchResult).toEqual({
|
|
||||||
kind: "unresolvedFileCandidate",
|
|
||||||
token: "dumm.md",
|
|
||||||
});
|
|
||||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
|
||||||
expect(result).toEqual({
|
|
||||||
kind: "unresolvedFileCandidate",
|
|
||||||
token: "dumm.md",
|
|
||||||
opened: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps failed ambiguous resolution out of the browser", async () => {
|
|
||||||
const openExternalUrl = vi.fn();
|
|
||||||
const onUnresolvedFileCandidate = vi.fn();
|
|
||||||
const resolver = createAssistantFileLinkResolver({
|
|
||||||
getDirectorySuggestions: vi.fn(async () => {
|
|
||||||
throw new Error("daemon unavailable");
|
|
||||||
}),
|
}),
|
||||||
openWorkspaceFile: vi.fn(),
|
).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts"));
|
||||||
openExternalUrl,
|
|
||||||
onUnresolvedFileCandidate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolver.open({
|
|
||||||
context: CONTEXT,
|
|
||||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
|
||||||
disposition: "main",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
|
||||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
|
||||||
expect(result).toEqual({
|
|
||||||
kind: "unresolvedFileCandidate",
|
|
||||||
token: "dumm.md",
|
|
||||||
opened: false,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws a typed unresolved error when the daemon throws", async () => {
|
||||||
|
const getDirectorySuggestions = vi.fn(async () => {
|
||||||
|
throw new Error("daemon unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchDaemonResolution({
|
||||||
|
ambiguousQuery: "dumm.md",
|
||||||
|
token: "dumm.md",
|
||||||
|
target: {
|
||||||
|
raw: "dumm.md",
|
||||||
|
path: "/Users/test/project/dumm.md",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
},
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
getDirectorySuggestions,
|
||||||
|
}),
|
||||||
|
).rejects.toEqual(new UnresolvedFileLinkError("dumm.md"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAssistantFileLinkToken", () => {
|
||||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||||
expect(
|
expect(
|
||||||
getAssistantFileLinkToken({
|
getAssistantFileLinkToken({
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ import {
|
|||||||
type AssistantFileLinkClassification,
|
type AssistantFileLinkClassification,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./parse";
|
} from "./parse";
|
||||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
|
||||||
|
|
||||||
export interface AssistantFileLinkContext {
|
|
||||||
serverId?: string;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssistantFileLinkSource {
|
export interface AssistantFileLinkSource {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -19,6 +13,10 @@ export interface AssistantFileLinkSource {
|
|||||||
sourceType?: "inline-code";
|
sourceType?: "inline-code";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkContext {
|
||||||
|
workspaceRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DirectorySuggestionEntry {
|
export interface DirectorySuggestionEntry {
|
||||||
path: string;
|
path: string;
|
||||||
kind: "file" | "directory";
|
kind: "file" | "directory";
|
||||||
@@ -29,152 +27,121 @@ export interface DirectorySuggestionResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssistantFileLinkResolverDependencies {
|
export type GetDirectorySuggestions = (input: {
|
||||||
getDirectorySuggestions: (input: {
|
query: string;
|
||||||
query: string;
|
cwd: string;
|
||||||
cwd: string;
|
includeFiles: true;
|
||||||
includeFiles: true;
|
includeDirectories: false;
|
||||||
includeDirectories: false;
|
matchMode: "suffix";
|
||||||
matchMode: "suffix";
|
limit: number;
|
||||||
limit: number;
|
}) => Promise<DirectorySuggestionResult>;
|
||||||
}) => Promise<DirectorySuggestionResult>;
|
|
||||||
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
|
||||||
openExternalUrl: (url: string) => void | Promise<void>;
|
|
||||||
onUnresolvedFileCandidate?: (token: string) => void;
|
|
||||||
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssistantFileLinkResolver {
|
|
||||||
prefetch(input: AssistantFileLinkPrefetchInput): Promise<ResolvedAssistantFileLink>;
|
|
||||||
open(input: AssistantFileLinkOpenInput): Promise<AssistantFileLinkOpenResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssistantFileLinkPrefetchInput {
|
|
||||||
context: AssistantFileLinkContext;
|
|
||||||
source: AssistantFileLinkSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput {
|
|
||||||
disposition: OpenFileDisposition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ResolvedAssistantFileLink =
|
export type ResolvedAssistantFileLink =
|
||||||
|
| { kind: "external"; url: string }
|
||||||
|
| { kind: "file"; target: InlinePathTarget }
|
||||||
|
| { kind: "ignored" };
|
||||||
|
|
||||||
|
export type AssistantFileLinkResolution =
|
||||||
|
| { kind: "resolved"; value: ResolvedAssistantFileLink }
|
||||||
| {
|
| {
|
||||||
kind: "external";
|
kind: "needsLookup";
|
||||||
url: string;
|
ambiguousQuery: string;
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "file";
|
|
||||||
target: InlinePathTarget;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "unresolvedFileCandidate";
|
|
||||||
token: string;
|
token: string;
|
||||||
}
|
target: InlinePathTarget;
|
||||||
| {
|
|
||||||
kind: "ignored";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
|
export interface FetchDaemonResolutionInput {
|
||||||
opened: boolean;
|
ambiguousQuery: string;
|
||||||
};
|
|
||||||
|
|
||||||
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
|
|
||||||
|
|
||||||
interface ParsedAssistantFileLinkInteraction {
|
|
||||||
token: string;
|
token: string;
|
||||||
classification: AssistantFileLinkClassification;
|
target: InlinePathTarget;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
getDirectorySuggestions: GetDirectorySuggestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAssistantFileLinkResolver(
|
export class UnresolvedFileLinkError extends Error {
|
||||||
dependencies: AssistantFileLinkResolverDependencies,
|
constructor(readonly token: string) {
|
||||||
): AssistantFileLinkResolver {
|
super(`No file found for ${token}`);
|
||||||
const cache = new Map<string, CachedAssistantFileLink>();
|
this.name = "UnresolvedFileLinkError";
|
||||||
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolve(
|
export async function fetchDaemonResolution({
|
||||||
input: AssistantFileLinkPrefetchInput,
|
ambiguousQuery,
|
||||||
): Promise<ResolvedAssistantFileLink> {
|
token,
|
||||||
const parsed = parseInteraction(input);
|
target,
|
||||||
if (!parsed) {
|
workspaceRoot,
|
||||||
return { kind: "ignored" };
|
getDirectorySuggestions,
|
||||||
}
|
}: FetchDaemonResolutionInput): Promise<InlinePathTarget> {
|
||||||
|
const trimmedRoot = workspaceRoot?.trim();
|
||||||
|
if (!trimmedRoot) {
|
||||||
|
throw new UnresolvedFileLinkError(token);
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.classification.kind === "external") {
|
let suggestions: DirectorySuggestionResult;
|
||||||
return { kind: "external", url: parsed.classification.raw };
|
try {
|
||||||
}
|
suggestions = await getDirectorySuggestions({
|
||||||
|
query: ambiguousQuery,
|
||||||
|
cwd: trimmedRoot,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: false,
|
||||||
|
matchMode: "suffix",
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new UnresolvedFileLinkError(token);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
||||||
parsed.classification.kind === "directFile" &&
|
if (!match || suggestions.error) {
|
||||||
!shouldResolveDirectFileThroughSuggestions({
|
throw new UnresolvedFileLinkError(token);
|
||||||
context: input.context,
|
|
||||||
source: input.source,
|
|
||||||
token: parsed.token,
|
|
||||||
target: parsed.classification.target,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return { kind: "file", target: parsed.classification.target };
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = getResolutionKey(input.context, parsed.token);
|
|
||||||
const cached = cache.get(key);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = inFlight.get(key);
|
|
||||||
if (active) {
|
|
||||||
return active;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = resolveAmbiguousCandidate({
|
|
||||||
context: input.context,
|
|
||||||
token: parsed.token,
|
|
||||||
target: parsed.classification.target,
|
|
||||||
getDirectorySuggestions: dependencies.getDirectorySuggestions,
|
|
||||||
})
|
|
||||||
.then((result) => {
|
|
||||||
if (result.kind === "file") {
|
|
||||||
cache.set(key, result);
|
|
||||||
}
|
|
||||||
inFlight.delete(key);
|
|
||||||
return result;
|
|
||||||
})
|
|
||||||
.catch((): CachedAssistantFileLink => {
|
|
||||||
inFlight.delete(key);
|
|
||||||
return { kind: "unresolvedFileCandidate", token: parsed.token };
|
|
||||||
});
|
|
||||||
|
|
||||||
inFlight.set(key, request);
|
|
||||||
return request;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prefetch(input) {
|
...target,
|
||||||
return resolve(input);
|
path: joinWorkspacePath(trimmedRoot, match.path),
|
||||||
},
|
};
|
||||||
async open(input) {
|
}
|
||||||
const resolved = await resolve(input);
|
|
||||||
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
|
|
||||||
return { ...resolved, opened: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolved.kind === "file") {
|
export function classifyForResolution(
|
||||||
dependencies.openWorkspaceFile(resolved.target, input.disposition);
|
source: AssistantFileLinkSource,
|
||||||
return { ...resolved, opened: true };
|
context: AssistantFileLinkContext,
|
||||||
}
|
): AssistantFileLinkResolution {
|
||||||
|
const token = getAssistantFileLinkToken(source).trim();
|
||||||
|
if (!token) {
|
||||||
|
return { kind: "resolved", value: { kind: "ignored" } };
|
||||||
|
}
|
||||||
|
|
||||||
if (resolved.kind === "external") {
|
const classification = classifyAssistantFileLink(token, {
|
||||||
await dependencies.openExternalUrl(resolved.url);
|
workspaceRoot: context.workspaceRoot,
|
||||||
return { ...resolved, opened: true };
|
});
|
||||||
}
|
if (!classification) {
|
||||||
|
return { kind: "resolved", value: { kind: "ignored" } };
|
||||||
|
}
|
||||||
|
if (classification.kind === "external") {
|
||||||
|
return { kind: "resolved", value: { kind: "external", url: classification.raw } };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
classification.kind === "directFile" &&
|
||||||
|
!shouldResolveDirectFileThroughSuggestions({
|
||||||
|
context,
|
||||||
|
source,
|
||||||
|
token,
|
||||||
|
target: classification.target,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return { kind: "resolved", value: { kind: "file", target: classification.target } };
|
||||||
|
}
|
||||||
|
|
||||||
if (resolved.kind === "unresolvedFileCandidate") {
|
const workspaceRoot = context.workspaceRoot?.trim();
|
||||||
dependencies.onUnresolvedFileCandidate?.(resolved.token);
|
if (!workspaceRoot) {
|
||||||
}
|
return { kind: "resolved", value: { kind: "ignored" } };
|
||||||
|
}
|
||||||
|
|
||||||
return { ...resolved, opened: false };
|
return {
|
||||||
},
|
kind: "needsLookup",
|
||||||
|
ambiguousQuery: getAmbiguousSuggestionQuery(classification.target, workspaceRoot),
|
||||||
|
token,
|
||||||
|
target: classification.target,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,59 +156,10 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri
|
|||||||
return source.href;
|
return source.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInteraction(
|
export function getAmbiguousSuggestionQuery(
|
||||||
input: AssistantFileLinkPrefetchInput,
|
target: InlinePathTarget,
|
||||||
): ParsedAssistantFileLinkInteraction | null {
|
workspaceRoot: string,
|
||||||
const token = getAssistantFileLinkToken(input.source).trim();
|
): string {
|
||||||
if (!token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const classification = classifyAssistantFileLink(token, {
|
|
||||||
workspaceRoot: input.context.workspaceRoot,
|
|
||||||
});
|
|
||||||
if (!classification) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { token, classification };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveAmbiguousCandidate(input: {
|
|
||||||
context: AssistantFileLinkContext;
|
|
||||||
token: string;
|
|
||||||
target: InlinePathTarget;
|
|
||||||
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
|
|
||||||
}): Promise<CachedAssistantFileLink> {
|
|
||||||
const workspaceRoot = input.context.workspaceRoot?.trim();
|
|
||||||
if (!workspaceRoot) {
|
|
||||||
return { kind: "unresolvedFileCandidate", token: input.token };
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
|
|
||||||
const suggestions = await input.getDirectorySuggestions({
|
|
||||||
query,
|
|
||||||
cwd: workspaceRoot,
|
|
||||||
includeFiles: true,
|
|
||||||
includeDirectories: false,
|
|
||||||
matchMode: "suffix",
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
|
||||||
if (!match || suggestions.error) {
|
|
||||||
return { kind: "unresolvedFileCandidate", token: input.token };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
kind: "file",
|
|
||||||
target: {
|
|
||||||
...input.target,
|
|
||||||
path: joinWorkspacePath(workspaceRoot, match.path),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
|
|
||||||
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const normalizedPath = target.path.replace(/\\/g, "/");
|
const normalizedPath = target.path.replace(/\\/g, "/");
|
||||||
const prefix = `${normalizedRoot}/`;
|
const prefix = `${normalizedRoot}/`;
|
||||||
@@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st
|
|||||||
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
|
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldResolveDirectFileThroughSuggestions(input: {
|
export function shouldResolveDirectFileThroughSuggestions(input: {
|
||||||
context: AssistantFileLinkContext;
|
context: AssistantFileLinkContext;
|
||||||
source: AssistantFileLinkSource;
|
source: AssistantFileLinkSource;
|
||||||
token: string;
|
token: string;
|
||||||
@@ -285,23 +203,14 @@ function isAbsoluteInlineCodeToken(token: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
|
|
||||||
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||||
return source.markup === "linkify" || source.sourceInfo === "auto";
|
return source.markup === "linkify" || source.sourceInfo === "auto";
|
||||||
}
|
}
|
||||||
|
|
||||||
function canApplyResult(
|
|
||||||
context: AssistantFileLinkContext,
|
|
||||||
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
|
|
||||||
): boolean {
|
|
||||||
return isCurrentContext ? isCurrentContext(context) : true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
|
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
|
||||||
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||||
return root ? `${root}/${child}` : child;
|
return root ? `${root}/${child}` : child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { AssistantFileLinkClassification };
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { InlinePathTarget } from "./parse";
|
||||||
|
import { AssistantFileLinkResolverProvider } from "./provider";
|
||||||
|
import type { DirectorySuggestionResult } from "./resolver";
|
||||||
|
import { useFileLink } from "./use-file-link";
|
||||||
|
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||||
|
|
||||||
|
vi.mock("@/utils/open-external-url", () => ({
|
||||||
|
openExternalUrl: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SOURCE = {
|
||||||
|
href: "http://dumm.md",
|
||||||
|
text: "dumm.md",
|
||||||
|
markup: "linkify",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolvedSuggestions(
|
||||||
|
entries: DirectorySuggestionResult["entries"],
|
||||||
|
): DirectorySuggestionResult {
|
||||||
|
return { entries, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
|
resolve = promiseResolve;
|
||||||
|
reject = promiseReject;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenedFile {
|
||||||
|
target: InlinePathTarget;
|
||||||
|
disposition: OpenFileDisposition;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestClient {
|
||||||
|
getDirectorySuggestions: (input: {
|
||||||
|
query: string;
|
||||||
|
cwd: string;
|
||||||
|
includeFiles: true;
|
||||||
|
includeDirectories: false;
|
||||||
|
matchMode: "suffix";
|
||||||
|
limit: number;
|
||||||
|
}) => Promise<DirectorySuggestionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWrapper(input: {
|
||||||
|
client: TestClient;
|
||||||
|
openedFiles: OpenedFile[];
|
||||||
|
toast?: {
|
||||||
|
show: ReturnType<typeof vi.fn>;
|
||||||
|
copied: ReturnType<typeof vi.fn>;
|
||||||
|
error: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const openWorkspaceFile = useCallback(
|
||||||
|
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||||
|
input.openedFiles.push({ target, disposition });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AssistantFileLinkResolverProvider
|
||||||
|
client={input.client}
|
||||||
|
serverId="server-1"
|
||||||
|
workspaceRoot="/Users/test/project"
|
||||||
|
onOpenWorkspaceFile={openWorkspaceFile}
|
||||||
|
toast={input.toast}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AssistantFileLinkResolverProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useFileLink", () => {
|
||||||
|
it("returns the same object across no-op parent rerenders", () => {
|
||||||
|
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const Provider = AssistantFileLinkResolverProvider as React.ComponentType<
|
||||||
|
Omit<React.ComponentProps<typeof AssistantFileLinkResolverProvider>, "children"> & {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
function ChurningProviderWrapper({ children }: { children: ReactNode }) {
|
||||||
|
return React.createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client: queryClient },
|
||||||
|
React.createElement(
|
||||||
|
Provider,
|
||||||
|
{
|
||||||
|
client: { getDirectorySuggestions },
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
onOpenWorkspaceFile: () => {},
|
||||||
|
toast: { show: vi.fn(), copied: vi.fn(), error: vi.fn() },
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(() => useFileLink({ ...SOURCE }), {
|
||||||
|
wrapper: ChurningProviderWrapper,
|
||||||
|
});
|
||||||
|
const first = result.current;
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect(result.current).toBe(first);
|
||||||
|
expect(result.current.onHoverIn).toBe(first.onHoverIn);
|
||||||
|
expect(result.current.onPress).toBe(first.onPress);
|
||||||
|
expect(result.current.onAuxPress).toBe(first.onAuxPress);
|
||||||
|
expect(result.current.open).toBe(first.open);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cache unresolved lookups forever", async () => {
|
||||||
|
const getDirectorySuggestions = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(resolvedSuggestions([]))
|
||||||
|
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||||
|
const openedFiles: OpenedFile[] = [];
|
||||||
|
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
|
||||||
|
const { result } = renderHook(() => useFileLink(SOURCE), {
|
||||||
|
wrapper: createWrapper({
|
||||||
|
client: { getDirectorySuggestions },
|
||||||
|
openedFiles,
|
||||||
|
toast,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onPress();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.show).toHaveBeenCalledWith("No file found for dumm.md", {
|
||||||
|
variant: "error",
|
||||||
|
testID: "assistant-file-link-not-found-toast",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onPress();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openedFiles).toEqual([
|
||||||
|
{
|
||||||
|
target: {
|
||||||
|
raw: "dumm.md",
|
||||||
|
path: "/Users/test/project/docs/dumm.md",
|
||||||
|
lineStart: undefined,
|
||||||
|
lineEnd: undefined,
|
||||||
|
},
|
||||||
|
disposition: "main",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("click retries after hover prefetch fails", async () => {
|
||||||
|
const getDirectorySuggestions = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("daemon unavailable"))
|
||||||
|
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||||
|
const openedFiles: OpenedFile[] = [];
|
||||||
|
const { result } = renderHook(() => useFileLink(SOURCE), {
|
||||||
|
wrapper: createWrapper({
|
||||||
|
client: { getDirectorySuggestions },
|
||||||
|
openedFiles,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onHoverIn();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onPress();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openedFiles).toHaveLength(1);
|
||||||
|
});
|
||||||
|
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes two links pointing at the same source", async () => {
|
||||||
|
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||||
|
const getDirectorySuggestions = vi.fn(() => deferred.promise);
|
||||||
|
const openedFiles: OpenedFile[] = [];
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => ({
|
||||||
|
first: useFileLink(SOURCE),
|
||||||
|
second: useFileLink(SOURCE),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
wrapper: createWrapper({
|
||||||
|
client: { getDirectorySuggestions },
|
||||||
|
openedFiles,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.first.onHoverIn();
|
||||||
|
result.current.second.onHoverIn();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.first.target?.path).toBe("/Users/test/project/docs/dumm.md");
|
||||||
|
expect(result.current.second.target?.path).toBe("/Users/test/project/docs/dumm.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hover then click uses the prefetched result", async () => {
|
||||||
|
const getDirectorySuggestions = vi.fn(async () =>
|
||||||
|
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
|
||||||
|
);
|
||||||
|
const openedFiles: OpenedFile[] = [];
|
||||||
|
const { result } = renderHook(() => useFileLink(SOURCE), {
|
||||||
|
wrapper: createWrapper({
|
||||||
|
client: { getDirectorySuggestions },
|
||||||
|
openedFiles,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onHoverIn();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.target?.path).toBe("/Users/test/project/docs/dumm.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onPress();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openedFiles).toHaveLength(1);
|
||||||
|
});
|
||||||
|
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open a stale result after the workspace changes", async () => {
|
||||||
|
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||||
|
const getDirectorySuggestions = vi.fn(() => deferred.promise);
|
||||||
|
const openedFiles: OpenedFile[] = [];
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const [workspaceRoot, setWorkspaceRoot] = useState("/Users/test/project");
|
||||||
|
const client = useMemo(() => ({ getDirectorySuggestions }), []);
|
||||||
|
const openWorkspaceFile = useCallback(
|
||||||
|
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||||
|
openedFiles.push({ target, disposition });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AssistantFileLinkResolverProvider
|
||||||
|
client={client}
|
||||||
|
serverId="server-1"
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
onOpenWorkspaceFile={openWorkspaceFile}
|
||||||
|
>
|
||||||
|
<WorkspaceSwitchContext.Provider value={setWorkspaceRoot}>
|
||||||
|
{children}
|
||||||
|
</WorkspaceSwitchContext.Provider>
|
||||||
|
</AssistantFileLinkResolverProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => ({
|
||||||
|
link: useFileLink(SOURCE),
|
||||||
|
setWorkspaceRoot: React.useContext(WorkspaceSwitchContext),
|
||||||
|
}),
|
||||||
|
{ wrapper: Wrapper },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.link.onPress();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.setWorkspaceRoot("/Users/test/other");
|
||||||
|
});
|
||||||
|
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(openedFiles).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const WorkspaceSwitchContext = React.createContext<(workspaceRoot: string) => void>(() => {});
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
|
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import type { InlinePathTarget } from "./parse";
|
||||||
|
import {
|
||||||
|
useAssistantFileLinkResolverContext,
|
||||||
|
type AssistantFileLinkResolverContextValue,
|
||||||
|
} from "./provider";
|
||||||
|
import {
|
||||||
|
classifyForResolution,
|
||||||
|
fetchDaemonResolution,
|
||||||
|
UnresolvedFileLinkError,
|
||||||
|
type AssistantFileLinkResolution,
|
||||||
|
type AssistantFileLinkSource,
|
||||||
|
} from "./resolver";
|
||||||
|
|
||||||
|
export interface UseFileLinkResult {
|
||||||
|
target: InlinePathTarget | null;
|
||||||
|
onHoverIn: () => void;
|
||||||
|
onPress: () => void;
|
||||||
|
onAuxPress: () => void;
|
||||||
|
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantFileLinkActions {
|
||||||
|
open(source: AssistantFileLinkSource, disposition: OpenFileDisposition): void;
|
||||||
|
canOpen(source: AssistantFileLinkSource): boolean;
|
||||||
|
canResolveFile(source: AssistantFileLinkSource): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssistantFileLinkQueryKey = readonly [
|
||||||
|
"assistantFileLink",
|
||||||
|
string | null,
|
||||||
|
string | null,
|
||||||
|
string,
|
||||||
|
];
|
||||||
|
|
||||||
|
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
||||||
|
|
||||||
|
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||||
|
const context = useAssistantFileLinkResolverContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const stableSource = useStableSource(source);
|
||||||
|
const activeConfig = context.configRef.current;
|
||||||
|
const workspaceRoot = activeConfig.workspaceRoot;
|
||||||
|
const serverId = activeConfig.serverId;
|
||||||
|
const resolution = useMemo(
|
||||||
|
() =>
|
||||||
|
classifyForResolution(stableSource, {
|
||||||
|
workspaceRoot,
|
||||||
|
}),
|
||||||
|
[stableSource, workspaceRoot],
|
||||||
|
);
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() =>
|
||||||
|
resolution.kind === "needsLookup"
|
||||||
|
? assistantFileLinkQueryKey({
|
||||||
|
serverId,
|
||||||
|
workspaceRoot,
|
||||||
|
ambiguousQuery: resolution.ambiguousQuery,
|
||||||
|
})
|
||||||
|
: DISABLED_QUERY_KEY,
|
||||||
|
[resolution, serverId, workspaceRoot],
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => {
|
||||||
|
if (resolution.kind !== "needsLookup") {
|
||||||
|
throw new Error("Assistant file link lookup requested for a sync link.");
|
||||||
|
}
|
||||||
|
return fetchDaemonResolution({
|
||||||
|
ambiguousQuery: resolution.ambiguousQuery,
|
||||||
|
token: resolution.token,
|
||||||
|
target: resolution.target,
|
||||||
|
workspaceRoot,
|
||||||
|
getDirectorySuggestions: context.getDirectorySuggestions,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: false,
|
||||||
|
retry: 0,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = useStableEvent(
|
||||||
|
(nextSource: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
||||||
|
openAssistantFileLink({
|
||||||
|
source: nextSource,
|
||||||
|
disposition,
|
||||||
|
context,
|
||||||
|
queryClient,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onHoverIn = useStableEvent(() => {
|
||||||
|
if (resolution.kind !== "needsLookup") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void queryClient.prefetchQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () =>
|
||||||
|
fetchDaemonResolution({
|
||||||
|
ambiguousQuery: resolution.ambiguousQuery,
|
||||||
|
token: resolution.token,
|
||||||
|
target: resolution.target,
|
||||||
|
workspaceRoot,
|
||||||
|
getDirectorySuggestions: context.getDirectorySuggestions,
|
||||||
|
}),
|
||||||
|
retry: 0,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPress = useStableEvent(() => {
|
||||||
|
open(stableSource, "main");
|
||||||
|
});
|
||||||
|
const onAuxPress = useStableEvent(() => {
|
||||||
|
open(stableSource, "side");
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = useMemo(() => {
|
||||||
|
if (resolution.kind === "resolved") {
|
||||||
|
return resolution.value.kind === "file" ? resolution.value.target : null;
|
||||||
|
}
|
||||||
|
return query.data ?? null;
|
||||||
|
}, [query.data, resolution]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ target, onHoverIn, onPress, onAuxPress, open }),
|
||||||
|
[target, onHoverIn, onPress, onAuxPress, open],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
|
||||||
|
const context = useAssistantFileLinkResolverContext();
|
||||||
|
const actionLink = useFileLink(ACTION_LINK_SOURCE);
|
||||||
|
|
||||||
|
const open = useStableEvent(
|
||||||
|
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
||||||
|
actionLink.open(source, disposition);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const canOpen = useCallback(
|
||||||
|
(source: AssistantFileLinkSource) =>
|
||||||
|
canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot),
|
||||||
|
[context.configRef],
|
||||||
|
);
|
||||||
|
const canResolveFile = useCallback(
|
||||||
|
(source: AssistantFileLinkSource) =>
|
||||||
|
canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot),
|
||||||
|
[context.configRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => ({ open, canOpen, canResolveFile }), [open, canOpen, canResolveFile]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssistantFileLink(input: {
|
||||||
|
source: AssistantFileLinkSource;
|
||||||
|
disposition: OpenFileDisposition;
|
||||||
|
context: AssistantFileLinkResolverContextValue;
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>;
|
||||||
|
}): void {
|
||||||
|
const capturedConfig = input.context.configRef.current;
|
||||||
|
const capturedResolution = classifyForResolution(input.source, {
|
||||||
|
workspaceRoot: capturedConfig.workspaceRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (capturedResolution.kind === "resolved") {
|
||||||
|
void dispatchResolvedLink({
|
||||||
|
resolution: capturedResolution,
|
||||||
|
disposition: input.disposition,
|
||||||
|
capturedServerId: capturedConfig.serverId,
|
||||||
|
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||||
|
context: input.context,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||||
|
serverId: capturedConfig.serverId,
|
||||||
|
workspaceRoot: capturedConfig.workspaceRoot,
|
||||||
|
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const target = await input.queryClient.fetchQuery({
|
||||||
|
queryKey: capturedQueryKey,
|
||||||
|
queryFn: () =>
|
||||||
|
fetchDaemonResolution({
|
||||||
|
ambiguousQuery: capturedResolution.ambiguousQuery,
|
||||||
|
token: capturedResolution.token,
|
||||||
|
target: capturedResolution.target,
|
||||||
|
workspaceRoot: capturedConfig.workspaceRoot,
|
||||||
|
getDirectorySuggestions: input.context.getDirectorySuggestions,
|
||||||
|
}),
|
||||||
|
retry: 0,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
await dispatchFileTarget({
|
||||||
|
target,
|
||||||
|
disposition: input.disposition,
|
||||||
|
capturedServerId: capturedConfig.serverId,
|
||||||
|
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||||
|
context: input.context,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await dispatchUnresolvedError({
|
||||||
|
error,
|
||||||
|
fallbackToken: capturedResolution.token,
|
||||||
|
capturedServerId: capturedConfig.serverId,
|
||||||
|
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||||
|
context: input.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canOpenAssistantFileLink(
|
||||||
|
source: AssistantFileLinkSource,
|
||||||
|
workspaceRoot: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
const resolution = classifyForResolution(source, { workspaceRoot });
|
||||||
|
return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canResolveAssistantFileLinkToFile(
|
||||||
|
source: AssistantFileLinkSource,
|
||||||
|
workspaceRoot: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
const resolution = classifyForResolution(source, { workspaceRoot });
|
||||||
|
return resolution.kind === "needsLookup" || resolution.value.kind === "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSource {
|
||||||
|
const { href, text, markup, sourceInfo, sourceType } = source;
|
||||||
|
return useMemo(
|
||||||
|
() => ({ href, text, markup, sourceInfo, sourceType }),
|
||||||
|
[href, text, markup, sourceInfo, sourceType],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantFileLinkQueryKey(input: {
|
||||||
|
serverId?: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
ambiguousQuery: string;
|
||||||
|
}): AssistantFileLinkQueryKey {
|
||||||
|
return [
|
||||||
|
"assistantFileLink",
|
||||||
|
input.serverId ?? null,
|
||||||
|
input.workspaceRoot ?? null,
|
||||||
|
input.ambiguousQuery,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchResolvedLink(input: {
|
||||||
|
resolution: Extract<AssistantFileLinkResolution, { kind: "resolved" }>;
|
||||||
|
disposition: OpenFileDisposition;
|
||||||
|
capturedServerId?: string;
|
||||||
|
capturedWorkspaceRoot?: string;
|
||||||
|
context: AssistantFileLinkResolverContextValue;
|
||||||
|
}) {
|
||||||
|
const { value } = input.resolution;
|
||||||
|
if (value.kind === "file") {
|
||||||
|
await dispatchFileTarget({
|
||||||
|
target: value.target,
|
||||||
|
disposition: input.disposition,
|
||||||
|
capturedServerId: input.capturedServerId,
|
||||||
|
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
|
||||||
|
context: input.context,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.kind === "external") {
|
||||||
|
await dispatchExternalUrl({
|
||||||
|
url: value.url,
|
||||||
|
capturedServerId: input.capturedServerId,
|
||||||
|
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
|
||||||
|
context: input.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchFileTarget(input: {
|
||||||
|
target: InlinePathTarget;
|
||||||
|
disposition: OpenFileDisposition;
|
||||||
|
capturedServerId?: string;
|
||||||
|
capturedWorkspaceRoot?: string;
|
||||||
|
context: AssistantFileLinkResolverContextValue;
|
||||||
|
}) {
|
||||||
|
const current = input.context.configRef.current;
|
||||||
|
if (
|
||||||
|
current.serverId !== input.capturedServerId ||
|
||||||
|
current.workspaceRoot !== input.capturedWorkspaceRoot
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.onOpenWorkspaceFile?.(input.target, input.disposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchExternalUrl(input: {
|
||||||
|
url: string;
|
||||||
|
capturedServerId?: string;
|
||||||
|
capturedWorkspaceRoot?: string;
|
||||||
|
context: AssistantFileLinkResolverContextValue;
|
||||||
|
}) {
|
||||||
|
const current = input.context.configRef.current;
|
||||||
|
if (
|
||||||
|
current.serverId !== input.capturedServerId ||
|
||||||
|
current.workspaceRoot !== input.capturedWorkspaceRoot
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await openExternalUrl(input.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchUnresolvedError(input: {
|
||||||
|
error: unknown;
|
||||||
|
fallbackToken: string;
|
||||||
|
capturedServerId?: string;
|
||||||
|
capturedWorkspaceRoot?: string;
|
||||||
|
context: AssistantFileLinkResolverContextValue;
|
||||||
|
}) {
|
||||||
|
const current = input.context.configRef.current;
|
||||||
|
if (
|
||||||
|
current.serverId !== input.capturedServerId ||
|
||||||
|
current.workspaceRoot !== input.capturedWorkspaceRoot
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token =
|
||||||
|
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
|
||||||
|
current.toast?.show(`No file found for ${token}`, {
|
||||||
|
variant: "error",
|
||||||
|
testID: "assistant-file-link-not-found-toast",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_LINK_SOURCE: AssistantFileLinkSource = {
|
||||||
|
href: "",
|
||||||
|
};
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { useMemo, useRef } from "react";
|
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
|
||||||
import type { ToastApi } from "@/components/toast-host";
|
|
||||||
import {
|
|
||||||
createAssistantFileLinkResolver,
|
|
||||||
type AssistantFileLinkContext,
|
|
||||||
type AssistantFileLinkOpenInput,
|
|
||||||
type AssistantFileLinkPrefetchInput,
|
|
||||||
} from "./resolver";
|
|
||||||
import type { InlinePathTarget } from "./parse";
|
|
||||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
|
||||||
|
|
||||||
export interface UseAssistantFileLinkResolverOptions {
|
|
||||||
client?: DaemonClient | null;
|
|
||||||
serverId?: string;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
|
||||||
toast?: ToastApi | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssistantFileLinkActions {
|
|
||||||
prefetch(input: Omit<AssistantFileLinkPrefetchInput, "context">): void;
|
|
||||||
open(input: Omit<AssistantFileLinkOpenInput, "context">): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAssistantFileLinkResolver({
|
|
||||||
client,
|
|
||||||
serverId,
|
|
||||||
workspaceRoot,
|
|
||||||
onOpenWorkspaceFile,
|
|
||||||
toast,
|
|
||||||
}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions {
|
|
||||||
const context: AssistantFileLinkContext = useMemo(
|
|
||||||
() => ({
|
|
||||||
serverId,
|
|
||||||
workspaceRoot,
|
|
||||||
}),
|
|
||||||
[serverId, workspaceRoot],
|
|
||||||
);
|
|
||||||
const latestContextRef = useRef(context);
|
|
||||||
latestContextRef.current = context;
|
|
||||||
|
|
||||||
const resolver = useMemo(
|
|
||||||
() =>
|
|
||||||
createAssistantFileLinkResolver({
|
|
||||||
async getDirectorySuggestions(input) {
|
|
||||||
if (!client) {
|
|
||||||
return { entries: [], error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await client.getDirectorySuggestions(input);
|
|
||||||
return {
|
|
||||||
entries: result.entries,
|
|
||||||
error: result.error,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
openWorkspaceFile(target, disposition) {
|
|
||||||
onOpenWorkspaceFile?.(target, disposition);
|
|
||||||
},
|
|
||||||
openExternalUrl,
|
|
||||||
onUnresolvedFileCandidate(token) {
|
|
||||||
toast?.show(`No file found for ${token}`, {
|
|
||||||
variant: "error",
|
|
||||||
testID: "assistant-file-link-not-found-toast",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
isCurrentContext(candidate) {
|
|
||||||
const current = latestContextRef.current;
|
|
||||||
return (
|
|
||||||
current.serverId === candidate.serverId &&
|
|
||||||
current.workspaceRoot === candidate.workspaceRoot
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[client, onOpenWorkspaceFile, toast],
|
|
||||||
);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
prefetch(input) {
|
|
||||||
void resolver.prefetch({ ...input, context });
|
|
||||||
},
|
|
||||||
open(input) {
|
|
||||||
void resolver.open({ ...input, context });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[context, resolver],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -202,6 +202,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
adaptiveInputOutline: {
|
||||||
|
outlineColor: theme.colors.accent,
|
||||||
|
},
|
||||||
|
adaptiveInputPlaceholder: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||||
@@ -234,10 +240,23 @@ export type AdaptiveTextInputProps = TextInputProps & {
|
|||||||
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
|
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
|
||||||
function AdaptiveTextInputInner(props, ref) {
|
function AdaptiveTextInputInner(props, ref) {
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props;
|
const {
|
||||||
|
value: _value,
|
||||||
|
initialValue,
|
||||||
|
resetKey,
|
||||||
|
defaultValue,
|
||||||
|
style,
|
||||||
|
placeholderTextColor,
|
||||||
|
...inputProps
|
||||||
|
} = props;
|
||||||
|
// Recolor the browser's :focus-visible outline (defined in public/index.html)
|
||||||
|
// so it matches the active theme's accent instead of its hard-coded fallback.
|
||||||
|
// Consumer style wins if it sets outlineColor explicitly.
|
||||||
const textInputProps = {
|
const textInputProps = {
|
||||||
...inputProps,
|
...inputProps,
|
||||||
defaultValue: initialValue ?? defaultValue,
|
defaultValue: initialValue ?? defaultValue,
|
||||||
|
placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color,
|
||||||
|
style: [styles.adaptiveInputOutline, style],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isMobile && isNative) {
|
if (isMobile && isNative) {
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ import {
|
|||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
} from "./use-bottom-anchor-controller";
|
} from "./use-bottom-anchor-controller";
|
||||||
import { normalizeInlinePathTarget } from "@/assistant-file-links";
|
import {
|
||||||
|
AssistantFileLinkResolverProvider,
|
||||||
|
normalizeInlinePathTarget,
|
||||||
|
} from "@/assistant-file-links";
|
||||||
import {
|
import {
|
||||||
createWorkspaceFileTabTarget,
|
createWorkspaceFileTabTarget,
|
||||||
normalizeWorkspaceFileLocation,
|
normalizeWorkspaceFileLocation,
|
||||||
@@ -83,6 +86,7 @@ import {
|
|||||||
} from "@/workspace/file-open";
|
} from "@/workspace/file-open";
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
@@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
setExpandedInlineToolCallIds(new Set());
|
setExpandedInlineToolCallIds(new Set());
|
||||||
}, [agentId]);
|
}, [agentId]);
|
||||||
|
|
||||||
const handleInlinePathPress = useCallback(
|
const handleInlinePathPress = useStableEvent(
|
||||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||||
if (!target.path) {
|
if (!target.path) {
|
||||||
return;
|
return;
|
||||||
@@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
checkout,
|
checkout,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
|
||||||
agent.cwd,
|
|
||||||
agent.projectPlacement?.checkout?.isGit,
|
|
||||||
isMobile,
|
|
||||||
openFileExplorerForCheckout,
|
|
||||||
onOpenWorkspaceFile,
|
|
||||||
requestDirectoryListing,
|
|
||||||
resolvedServerId,
|
|
||||||
setExplorerTabForCheckout,
|
|
||||||
workspaceId,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToolCallOpenFile = useCallback(
|
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
|
||||||
(filePath: string) => {
|
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
});
|
||||||
},
|
|
||||||
[handleInlinePathPress],
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseRenderModel = useMemo(() => {
|
const baseRenderModel = useMemo(() => {
|
||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
@@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
belowItem,
|
belowItem,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<AssistantMessage
|
<AssistantFileLinkResolverProvider
|
||||||
message={item.text}
|
|
||||||
timestamp={item.timestamp.getTime()}
|
|
||||||
onInlinePathPress={handleInlinePathPress}
|
|
||||||
workspaceRoot={workspaceRoot}
|
|
||||||
serverId={serverId}
|
|
||||||
client={client}
|
client={client}
|
||||||
|
serverId={resolvedServerId}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
onOpenWorkspaceFile={handleInlinePathPress}
|
||||||
toast={toast}
|
toast={toast}
|
||||||
spacing={spacing}
|
>
|
||||||
/>
|
<AssistantMessage
|
||||||
|
message={item.text}
|
||||||
|
timestamp={item.timestamp.getTime()}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
serverId={resolvedServerId}
|
||||||
|
client={client}
|
||||||
|
spacing={spacing}
|
||||||
|
/>
|
||||||
|
</AssistantFileLinkResolverProvider>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast],
|
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderThoughtItem = useCallback(
|
const renderThoughtItem = useCallback(
|
||||||
|
|||||||
@@ -68,9 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages";
|
|||||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
|
||||||
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
||||||
import type { ToastApi } from "@/components/toast-host";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||||
@@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet";
|
|||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import {
|
import {
|
||||||
AssistantInlineCodePathLink,
|
AssistantInlineCodePathLink,
|
||||||
classifyAssistantFileLink,
|
|
||||||
type AssistantFileLinkSource,
|
type AssistantFileLinkSource,
|
||||||
AssistantMarkdownCodeLink,
|
AssistantMarkdownCodeLink,
|
||||||
AssistantMarkdownLink,
|
AssistantMarkdownLink,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
useAssistantFileLinkResolver,
|
useAssistantFileLinkActions,
|
||||||
} from "@/assistant-file-links";
|
} from "@/assistant-file-links";
|
||||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
@@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({
|
|||||||
interface AssistantMessageProps {
|
interface AssistantMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
client?: DaemonClient | null;
|
client?: DaemonClient | null;
|
||||||
toast?: ToastApi | null;
|
|
||||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie
|
|||||||
export const AssistantMessage = memo(function AssistantMessage({
|
export const AssistantMessage = memo(function AssistantMessage({
|
||||||
message,
|
message,
|
||||||
timestamp: _timestamp,
|
timestamp: _timestamp,
|
||||||
onInlinePathPress,
|
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
serverId,
|
serverId,
|
||||||
client,
|
client,
|
||||||
toast,
|
|
||||||
spacing = "default",
|
spacing = "default",
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const markdownParser = useMemo(() => {
|
const markdownParser = useMemo(() => {
|
||||||
@@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
return parser;
|
return parser;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fileLinkResolver = useAssistantFileLinkResolver({
|
const fileLinkActions = useAssistantFileLinkActions();
|
||||||
client,
|
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||||
serverId,
|
fileLinkActions.open({ href: url }, "main");
|
||||||
workspaceRoot,
|
// react-native-markdown-display opens the link itself when this returns true.
|
||||||
onOpenWorkspaceFile: onInlinePathPress,
|
// We already handled it above, so return false to avoid duplicate opens.
|
||||||
toast,
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLinkPress = useCallback(
|
|
||||||
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
|
|
||||||
fileLinkResolver.open({ source, disposition });
|
|
||||||
},
|
|
||||||
[fileLinkResolver],
|
|
||||||
);
|
|
||||||
const handleLinkPrefetch = useCallback(
|
|
||||||
(source: AssistantFileLinkSource) => {
|
|
||||||
fileLinkResolver.prefetch({ source });
|
|
||||||
},
|
|
||||||
[fileLinkResolver],
|
|
||||||
);
|
|
||||||
const handleMarkdownLinkPress = useCallback(
|
|
||||||
(url: string) => {
|
|
||||||
fileLinkResolver.open({ source: { href: url }, disposition: "main" });
|
|
||||||
// react-native-markdown-display opens the link itself when this returns true.
|
|
||||||
// We already handled it above, so return false to avoid duplicate opens.
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
[fileLinkResolver],
|
|
||||||
);
|
|
||||||
|
|
||||||
const markdownRules = useMemo<RenderRules>(() => {
|
const markdownRules = useMemo<RenderRules>(() => {
|
||||||
return {
|
return {
|
||||||
text: (
|
text: (
|
||||||
@@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
) => {
|
) => {
|
||||||
const content = node.content ?? "";
|
const content = node.content ?? "";
|
||||||
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
||||||
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
|
const inlineCodeSource: AssistantFileLinkSource = {
|
||||||
|
href: content,
|
||||||
|
text: content,
|
||||||
|
sourceType: "inline-code",
|
||||||
|
};
|
||||||
const shouldResolveInlinePath =
|
const shouldResolveInlinePath =
|
||||||
onInlinePathPress &&
|
!isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource);
|
||||||
!isLinkedInlineCode &&
|
|
||||||
inlineCodeFileLink &&
|
|
||||||
inlineCodeFileLink.kind !== "external";
|
|
||||||
|
|
||||||
if (shouldResolveInlinePath) {
|
if (shouldResolveInlinePath) {
|
||||||
return (
|
return (
|
||||||
@@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
inheritedStyles={inheritedStyles}
|
inheritedStyles={inheritedStyles}
|
||||||
codeInlineStyle={styles.code_inline}
|
codeInlineStyle={styles.code_inline}
|
||||||
linkStyle={styles.link}
|
linkStyle={styles.link}
|
||||||
onPress={handleLinkPress}
|
|
||||||
onPrefetch={handleLinkPrefetch}
|
|
||||||
workspaceRoot={workspaceRoot}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
inheritedStyles={inheritedStyles}
|
inheritedStyles={inheritedStyles}
|
||||||
codeInlineStyle={styles.code_inline}
|
codeInlineStyle={styles.code_inline}
|
||||||
linkStyle={styles.link}
|
linkStyle={styles.link}
|
||||||
onPress={handleLinkPress}
|
|
||||||
onPrefetch={handleLinkPrefetch}
|
|
||||||
workspaceRoot={workspaceRoot}
|
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</AssistantMarkdownCodeLink>
|
</AssistantMarkdownCodeLink>
|
||||||
@@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
key={node.key}
|
key={node.key}
|
||||||
source={getMarkdownLinkSource(node)}
|
source={getMarkdownLinkSource(node)}
|
||||||
style={styles.link}
|
style={styles.link}
|
||||||
onPress={handleLinkPress}
|
|
||||||
onPrefetch={handleLinkPrefetch}
|
|
||||||
workspaceRoot={workspaceRoot}
|
|
||||||
>
|
>
|
||||||
{Children.map(children, (child) => {
|
{Children.map(children, (child) => {
|
||||||
if (!isValidElement(child)) return child;
|
if (!isValidElement(child)) return child;
|
||||||
@@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [
|
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
|
||||||
client,
|
|
||||||
handleLinkPrefetch,
|
|
||||||
handleLinkPress,
|
|
||||||
markdownParser,
|
|
||||||
onInlinePathPress,
|
|
||||||
serverId,
|
|
||||||
workspaceRoot,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||||
const keyedBlocks = useMemo(
|
const keyedBlocks = useMemo(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bot } from "lucide-react-native";
|
import { Bot, PackagePlus } from "lucide-react-native";
|
||||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||||
@@ -9,6 +9,7 @@ const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
|||||||
claude: ClaudeIcon as unknown as typeof Bot,
|
claude: ClaudeIcon as unknown as typeof Bot,
|
||||||
codex: CodexIcon as unknown as typeof Bot,
|
codex: CodexIcon as unknown as typeof Bot,
|
||||||
copilot: CopilotIcon as unknown as typeof Bot,
|
copilot: CopilotIcon as unknown as typeof Bot,
|
||||||
|
kiro: PackagePlus,
|
||||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||||
pi: PiIcon as unknown as typeof Bot,
|
pi: PiIcon as unknown as typeof Bot,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AdaptiveRenameModal } from "./rename-modal";
|
||||||
|
|
||||||
|
const { theme, adaptiveInputState } = vi.hoisted(() => ({
|
||||||
|
adaptiveInputState: {
|
||||||
|
latestProps: null as {
|
||||||
|
onChangeText?: (next: string) => void;
|
||||||
|
onSubmitEditing?: () => void;
|
||||||
|
} | null,
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
spacing: { 2: 8, 3: 12 },
|
||||||
|
fontSize: { sm: 13, base: 15 },
|
||||||
|
borderRadius: { md: 6 },
|
||||||
|
colors: {
|
||||||
|
surface0: "#000",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
border: "#555",
|
||||||
|
palette: { red: { 300: "#f87171" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: true,
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/adaptive-modal-sheet", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
const AdaptiveModalSheet = ({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) => {
|
||||||
|
if (!visible) return null;
|
||||||
|
return ReactModule.createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title },
|
||||||
|
ReactModule.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
"data-testid": "adaptive-modal-sheet-close",
|
||||||
|
onClick: onClose,
|
||||||
|
},
|
||||||
|
"Close",
|
||||||
|
),
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// Mirrors production AdaptiveTextInput: native-owned input seeded by
|
||||||
|
// initialValue, remounted (via key) when resetKey changes so the new
|
||||||
|
// initialValue takes effect.
|
||||||
|
const AdaptiveTextInput = ReactModule.forwardRef<HTMLInputElement, Record<string, unknown>>(
|
||||||
|
(props, ref) => {
|
||||||
|
const p = props as {
|
||||||
|
initialValue?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
editable?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
testID?: string;
|
||||||
|
onChangeText?: (next: string) => void;
|
||||||
|
onSubmitEditing?: () => void;
|
||||||
|
};
|
||||||
|
adaptiveInputState.latestProps = {
|
||||||
|
onChangeText: p.onChangeText,
|
||||||
|
onSubmitEditing: p.onSubmitEditing,
|
||||||
|
};
|
||||||
|
return ReactModule.createElement("input", {
|
||||||
|
ref,
|
||||||
|
defaultValue: p.initialValue ?? p.defaultValue ?? "",
|
||||||
|
disabled: p.editable === false,
|
||||||
|
maxLength: p.maxLength,
|
||||||
|
"data-testid": p.testID,
|
||||||
|
onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value),
|
||||||
|
onKeyDown: (e: { key: string; preventDefault: () => void }) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
p.onSubmitEditing?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { AdaptiveModalSheet, AdaptiveTextInput };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/button", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
return {
|
||||||
|
Button: ({
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
}) =>
|
||||||
|
ReactModule.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
"data-testid": testID,
|
||||||
|
disabled: disabled || undefined,
|
||||||
|
onClick: () => !disabled && onPress?.(),
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
vi.stubGlobal("window", dom.window);
|
||||||
|
vi.stubGlobal("document", dom.window.document);
|
||||||
|
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||||
|
vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement);
|
||||||
|
vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent);
|
||||||
|
vi.stubGlobal("Node", dom.window.Node);
|
||||||
|
vi.stubGlobal("navigator", dom.window.navigator);
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
adaptiveInputState.latestProps = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RenderOptions {
|
||||||
|
visible?: boolean;
|
||||||
|
initialValue?: string;
|
||||||
|
title?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
onSubmit?: (value: string) => Promise<void> | void;
|
||||||
|
validate?: (value: string) => string | null;
|
||||||
|
maxLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModal(options: RenderOptions = {}): void {
|
||||||
|
const {
|
||||||
|
visible = true,
|
||||||
|
initialValue = "",
|
||||||
|
title = "Rename",
|
||||||
|
placeholder,
|
||||||
|
submitLabel,
|
||||||
|
onClose = vi.fn(),
|
||||||
|
onSubmit = vi.fn(),
|
||||||
|
validate,
|
||||||
|
maxLength,
|
||||||
|
} = options;
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<AdaptiveRenameModal
|
||||||
|
visible={visible}
|
||||||
|
title={title}
|
||||||
|
initialValue={initialValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
submitLabel={submitLabel}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
validate={validate}
|
||||||
|
maxLength={maxLength}
|
||||||
|
testID="rename-modal"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryInput(): HTMLInputElement | null {
|
||||||
|
return document.querySelector<HTMLInputElement>('[data-testid="rename-modal-input"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function querySubmit(): HTMLButtonElement | null {
|
||||||
|
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryCancel(): HTMLButtonElement | null {
|
||||||
|
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-cancel"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryError(): HTMLElement | null {
|
||||||
|
return document.querySelector<HTMLElement>('[data-testid="rename-modal-error"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(element: Element | null): void {
|
||||||
|
if (!element) throw new Error("Cannot click null element");
|
||||||
|
act(() => {
|
||||||
|
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeInto(value: string): void {
|
||||||
|
act(() => {
|
||||||
|
adaptiveInputState.latestProps?.onChangeText?.(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressEnter(): void {
|
||||||
|
act(() => {
|
||||||
|
adaptiveInputState.latestProps?.onSubmitEditing?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RenameModal", () => {
|
||||||
|
it("renders with the initial value pre-filled and selects it after open", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
renderModal({ initialValue: "main" });
|
||||||
|
const input = queryInput();
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
expect(input?.value).toBe("main");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const focused = document.activeElement as HTMLInputElement | null;
|
||||||
|
expect(focused).toBe(input);
|
||||||
|
expect(focused?.selectionStart).toBe(0);
|
||||||
|
expect(focused?.selectionEnd).toBe("main".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits on Enter keypress in the input when the value has changed", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ initialValue: "feature", onSubmit, onClose });
|
||||||
|
|
||||||
|
typeInto("feature-2");
|
||||||
|
pressEnter();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith("feature-2");
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when AdaptiveModalSheet's close prop fires (cancel button / backdrop delegated)", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
renderModal({ initialValue: "main", onClose, onSubmit });
|
||||||
|
|
||||||
|
click(document.querySelector('[data-testid="adaptive-modal-sheet-close"]'));
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables submit when draft equals initialValue and re-enables after a change", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
renderModal({ initialValue: "main", onSubmit });
|
||||||
|
|
||||||
|
expect(querySubmit()?.disabled).toBe(true);
|
||||||
|
|
||||||
|
pressEnter();
|
||||||
|
await flush();
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
typeInto("main-v2");
|
||||||
|
await flush();
|
||||||
|
expect(querySubmit()?.disabled).toBe(false);
|
||||||
|
|
||||||
|
typeInto("main");
|
||||||
|
await flush();
|
||||||
|
expect(querySubmit()?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces validate errors inline and blocks submission", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const validate = vi.fn((value: string) => (value === "bad" ? "Invalid name" : null));
|
||||||
|
renderModal({ initialValue: "ok", validate, onSubmit });
|
||||||
|
|
||||||
|
typeInto("bad");
|
||||||
|
const submit = querySubmit()!;
|
||||||
|
expect(submit.disabled).toBe(true);
|
||||||
|
|
||||||
|
pressEnter();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
const errorNode = queryError();
|
||||||
|
expect(errorNode?.textContent).toContain("Invalid name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the submit button while onSubmit is pending", async () => {
|
||||||
|
let resolve: () => void = () => {};
|
||||||
|
const onSubmit = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderModal({ initialValue: "main", onSubmit });
|
||||||
|
|
||||||
|
typeInto("main-renamed");
|
||||||
|
click(querySubmit());
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(querySubmit()?.disabled).toBe(true);
|
||||||
|
expect(queryCancel()?.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the modal open with an error when onSubmit rejects", async () => {
|
||||||
|
const onSubmit = vi.fn().mockRejectedValue(new Error("Server said no"));
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal({ initialValue: "main", onSubmit, onClose });
|
||||||
|
|
||||||
|
typeInto("main-renamed");
|
||||||
|
click(querySubmit());
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(queryError()?.textContent).toContain("Server said no");
|
||||||
|
expect(querySubmit()?.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Text, TextInput, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import {
|
||||||
|
AdaptiveModalSheet,
|
||||||
|
AdaptiveTextInput,
|
||||||
|
type SheetHeader,
|
||||||
|
} from "@/components/adaptive-modal-sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
|
export interface AdaptiveRenameModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
initialValue: string;
|
||||||
|
placeholder?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (value: string) => Promise<void> | void;
|
||||||
|
validate?: (value: string) => string | null;
|
||||||
|
maxLength?: number;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdaptiveRenameModal({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
initialValue,
|
||||||
|
placeholder,
|
||||||
|
submitLabel = "Rename",
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
validate,
|
||||||
|
maxLength,
|
||||||
|
testID,
|
||||||
|
}: AdaptiveRenameModalProps) {
|
||||||
|
const [draft, setDraft] = useState(initialValue);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, setIsPending] = useState(false);
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
setDraft(initialValue);
|
||||||
|
setError(null);
|
||||||
|
setIsPending(false);
|
||||||
|
}, [visible, initialValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const length = initialValue.length;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const node = inputRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
node.focus();
|
||||||
|
if (isWeb && node instanceof HTMLInputElement) {
|
||||||
|
node.setSelectionRange(0, length);
|
||||||
|
} else if (!isWeb && length > 0) {
|
||||||
|
node.setNativeProps({ selection: { start: 0, end: length } });
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [visible, initialValue]);
|
||||||
|
|
||||||
|
const computeError = useCallback(
|
||||||
|
(value: string): string | null => {
|
||||||
|
if (!value.trim()) return "Name is required";
|
||||||
|
return validate ? validate(value) : null;
|
||||||
|
},
|
||||||
|
[validate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback((value: string) => {
|
||||||
|
setDraft(value);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (isPending) return;
|
||||||
|
const value = draft;
|
||||||
|
if (value === initialValue) return;
|
||||||
|
const validationError = computeError(value);
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsPending(true);
|
||||||
|
await onSubmit(value);
|
||||||
|
setIsPending(false);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setIsPending(false);
|
||||||
|
const message = err instanceof Error && err.message ? err.message : "Unable to save";
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
}, [isPending, draft, initialValue, computeError, onSubmit, onClose]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
if (isPending) return;
|
||||||
|
onClose();
|
||||||
|
}, [isPending, onClose]);
|
||||||
|
|
||||||
|
const handleSubmitVoid = useCallback(() => {
|
||||||
|
void handleSubmit();
|
||||||
|
}, [handleSubmit]);
|
||||||
|
|
||||||
|
const submitDisabled = isPending || draft === initialValue || computeError(draft) !== null;
|
||||||
|
const inputTestID = testID ? `${testID}-input` : undefined;
|
||||||
|
const errorTestID = testID ? `${testID}-error` : undefined;
|
||||||
|
const submitTestID = testID ? `${testID}-submit` : undefined;
|
||||||
|
const cancelTestID = testID ? `${testID}-cancel` : undefined;
|
||||||
|
const sheetHeader = useMemo<SheetHeader>(() => ({ title }), [title]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdaptiveModalSheet
|
||||||
|
visible={visible}
|
||||||
|
onClose={handleCancel}
|
||||||
|
header={sheetHeader}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
<View style={styles.body}>
|
||||||
|
<AdaptiveTextInput
|
||||||
|
ref={inputRef}
|
||||||
|
initialValue={initialValue}
|
||||||
|
onChangeText={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isPending}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onSubmitEditing={handleSubmitVoid}
|
||||||
|
style={styles.input}
|
||||||
|
testID={inputTestID}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<Text style={styles.errorText} testID={errorTestID}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={isPending}
|
||||||
|
testID={cancelTestID}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={handleSubmitVoid}
|
||||||
|
disabled={submitDisabled}
|
||||||
|
testID={submitTestID}
|
||||||
|
>
|
||||||
|
{isPending ? "Saving..." : submitLabel}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</AdaptiveModalSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
body: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.palette.red[300],
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useQueries } from "@tanstack/react-query";
|
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@server/utils/branch-slug";
|
||||||
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
|
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -45,6 +48,7 @@ import {
|
|||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
Monitor,
|
Monitor,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
@@ -147,6 +151,7 @@ const ThemedTrash2 = withUnistyles(Trash2);
|
|||||||
const ThemedSettings = withUnistyles(Settings);
|
const ThemedSettings = withUnistyles(Settings);
|
||||||
const ThemedCopy = withUnistyles(Copy);
|
const ThemedCopy = withUnistyles(Copy);
|
||||||
const ThemedArchive = withUnistyles(Archive);
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
@@ -232,6 +237,7 @@ interface WorkspaceRowInnerProps {
|
|||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onCopyBranchName?: () => void;
|
onCopyBranchName?: () => void;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +571,7 @@ const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColor
|
|||||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
|
|
||||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -640,6 +647,7 @@ function WorkspaceRowRightGroup({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onCopyBranchName,
|
onCopyBranchName,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
|
onRename,
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
@@ -656,6 +664,7 @@ function WorkspaceRowRightGroup({
|
|||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onCopyBranchName?: () => void;
|
onCopyBranchName?: () => void;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||||
return (
|
return (
|
||||||
@@ -675,6 +684,7 @@ function WorkspaceRowRightGroup({
|
|||||||
workspaceKey={workspace.workspaceKey}
|
workspaceKey={workspace.workspaceKey}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
onCopyBranchName={onCopyBranchName}
|
onCopyBranchName={onCopyBranchName}
|
||||||
|
onRename={onRename}
|
||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
archiveLabel={archiveLabel}
|
archiveLabel={archiveLabel}
|
||||||
archiveStatus={archiveStatus}
|
archiveStatus={archiveStatus}
|
||||||
@@ -701,6 +711,7 @@ function WorkspaceKebabMenu({
|
|||||||
workspaceKey,
|
workspaceKey,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
onCopyBranchName,
|
onCopyBranchName,
|
||||||
|
onRename,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveLabel,
|
archiveLabel,
|
||||||
archiveStatus,
|
archiveStatus,
|
||||||
@@ -710,6 +721,7 @@ function WorkspaceKebabMenu({
|
|||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
onCopyPath?: () => void;
|
onCopyPath?: () => void;
|
||||||
onCopyBranchName?: () => void;
|
onCopyBranchName?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
archiveLabel?: string;
|
archiveLabel?: string;
|
||||||
archiveStatus?: "idle" | "pending" | "success";
|
archiveStatus?: "idle" | "pending" | "success";
|
||||||
@@ -750,6 +762,15 @@ function WorkspaceKebabMenu({
|
|||||||
Copy branch name
|
Copy branch name
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{onRename ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||||
|
leading={renameLeadingIcon}
|
||||||
|
onSelect={onRename}
|
||||||
|
>
|
||||||
|
Rename workspace
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||||
leading={archiveLeadingIcon}
|
leading={archiveLeadingIcon}
|
||||||
@@ -1319,6 +1340,7 @@ function WorkspaceRowInner({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onCopyBranchName,
|
onCopyBranchName,
|
||||||
onCopyPath,
|
onCopyPath,
|
||||||
|
onRename,
|
||||||
archiveShortcutKeys,
|
archiveShortcutKeys,
|
||||||
}: WorkspaceRowInnerProps) {
|
}: WorkspaceRowInnerProps) {
|
||||||
const _isCompact = useIsCompactFormFactor();
|
const _isCompact = useIsCompactFormFactor();
|
||||||
@@ -1429,6 +1451,7 @@ function WorkspaceRowInner({
|
|||||||
onArchive={onArchive}
|
onArchive={onArchive}
|
||||||
onCopyBranchName={onCopyBranchName}
|
onCopyBranchName={onCopyBranchName}
|
||||||
onCopyPath={onCopyPath}
|
onCopyPath={onCopyPath}
|
||||||
|
onRename={onRename}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{prHint ? (
|
{prHint ? (
|
||||||
@@ -1469,7 +1492,9 @@ function WorkspaceRowWithMenu({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||||
|
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
});
|
});
|
||||||
@@ -1599,6 +1624,51 @@ function WorkspaceRowWithMenu({
|
|||||||
toast.copied("Branch name copied");
|
toast.copied("Branch name copied");
|
||||||
}, [toast, workspace.name]);
|
}, [toast, workspace.name]);
|
||||||
|
|
||||||
|
const renameMutation = useMutation({
|
||||||
|
mutationFn: async (branch: string) => {
|
||||||
|
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Host is not connected");
|
||||||
|
}
|
||||||
|
const targetCwd = requireWorkspaceExecutionDirectory({
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
});
|
||||||
|
const payload = await client.renameBranch({ cwd: targetCwd, branch });
|
||||||
|
if (!payload.success || payload.error) {
|
||||||
|
throw new Error(payload.error?.message ?? "Failed to rename branch");
|
||||||
|
}
|
||||||
|
return { targetCwd };
|
||||||
|
},
|
||||||
|
onSuccess: async ({ targetCwd }) => {
|
||||||
|
await invalidateCheckoutGitQueriesForClient(queryClient, {
|
||||||
|
serverId: workspace.serverId,
|
||||||
|
cwd: targetCwd,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpenRename = useCallback(() => {
|
||||||
|
setIsRenameOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseRename = useCallback(() => {
|
||||||
|
setIsRenameOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmitRename = useCallback(
|
||||||
|
async (value: string) => {
|
||||||
|
await renameMutation.mutateAsync(slugify(value));
|
||||||
|
},
|
||||||
|
[renameMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateRenameSlug = useCallback((value: string): string | null => {
|
||||||
|
const result = validateBranchSlug(slugify(value));
|
||||||
|
if (result.valid) return null;
|
||||||
|
return result.error ?? "Invalid branch name";
|
||||||
|
}, []);
|
||||||
|
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
@@ -1617,26 +1687,41 @@ function WorkspaceRowWithMenu({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRowInner
|
<>
|
||||||
workspace={workspace}
|
<WorkspaceRowInner
|
||||||
selected={selected}
|
workspace={workspace}
|
||||||
shortcutNumber={shortcutNumber}
|
selected={selected}
|
||||||
showShortcutBadge={showShortcutBadge}
|
shortcutNumber={shortcutNumber}
|
||||||
onPress={onPress}
|
showShortcutBadge={showShortcutBadge}
|
||||||
drag={drag}
|
onPress={onPress}
|
||||||
isDragging={isDragging}
|
drag={drag}
|
||||||
isArchiving={isArchiving}
|
isDragging={isDragging}
|
||||||
isCreating={isCreating}
|
isArchiving={isArchiving}
|
||||||
dragHandleProps={dragHandleProps}
|
isCreating={isCreating}
|
||||||
menuController={null}
|
dragHandleProps={dragHandleProps}
|
||||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
menuController={null}
|
||||||
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
||||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||||
onCopyPath={handleCopyPath}
|
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
onCopyPath={handleCopyPath}
|
||||||
/>
|
onRename={canCopyBranchName ? handleOpenRename : undefined}
|
||||||
|
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||||
|
/>
|
||||||
|
<AdaptiveRenameModal
|
||||||
|
visible={isRenameOpen}
|
||||||
|
title="Rename workspace"
|
||||||
|
initialValue={workspace.name}
|
||||||
|
placeholder="branch-name"
|
||||||
|
submitLabel="Rename"
|
||||||
|
validate={validateRenameSlug}
|
||||||
|
maxLength={MAX_SLUG_LENGTH}
|
||||||
|
onClose={handleCloseRename}
|
||||||
|
onSubmit={handleSubmitRename}
|
||||||
|
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ interface SplitContainerProps {
|
|||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||||
|
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||||
@@ -362,6 +363,7 @@ export function SplitContainer({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -577,6 +579,7 @@ export function SplitContainer({
|
|||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
onReloadAgent={onReloadAgent}
|
onReloadAgent={onReloadAgent}
|
||||||
|
onRenameTab={onRenameTab}
|
||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
@@ -716,6 +719,7 @@ function SplitNodeView({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -768,6 +772,7 @@ function SplitNodeView({
|
|||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
onReloadAgent={onReloadAgent}
|
onReloadAgent={onReloadAgent}
|
||||||
|
onRenameTab={onRenameTab}
|
||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
@@ -813,6 +818,7 @@ function SplitNodeView({
|
|||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
onReloadAgent={onReloadAgent}
|
onReloadAgent={onReloadAgent}
|
||||||
|
onRenameTab={onRenameTab}
|
||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
@@ -864,6 +870,7 @@ function SplitPaneView({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -1004,6 +1011,7 @@ function SplitPaneView({
|
|||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
onReloadAgent={onReloadAgent}
|
onReloadAgent={onReloadAgent}
|
||||||
|
onRenameTab={onRenameTab}
|
||||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||||
onCloseTabsToRight={handleCloseTabsToRight}
|
onCloseTabsToRight={handleCloseTabsToRight}
|
||||||
onCloseOtherTabs={handleCloseOtherTabs}
|
onCloseOtherTabs={handleCloseOtherTabs}
|
||||||
|
|||||||
@@ -240,6 +240,15 @@ const CATALOG_DATA = [
|
|||||||
installLink: "https://kilo.ai/docs/code-with-ai/platforms/cli",
|
installLink: "https://kilo.ai/docs/code-with-ai/platforms/cli",
|
||||||
command: ["kilo", "acp"],
|
command: ["kilo", "acp"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "kiro",
|
||||||
|
title: "Kiro CLI",
|
||||||
|
description: "Amazon's AI coding agent with native ACP support",
|
||||||
|
version: "manual",
|
||||||
|
iconId: null,
|
||||||
|
installLink: "https://kiro.dev/docs/cli/acp/",
|
||||||
|
command: ["kiro-cli", "acp"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "kimi",
|
id: "kimi",
|
||||||
title: "Kimi CLI",
|
title: "Kimi CLI",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("ACP provider catalog", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("bundles SVG icons for catalog entries that declare an icon", () => {
|
it("bundles SVG icons for catalog entries that declare an icon", () => {
|
||||||
const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.id !== "hermes");
|
const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.iconSvg !== null);
|
||||||
|
|
||||||
expect(entriesWithIcons.length).toBeGreaterThan(0);
|
expect(entriesWithIcons.length).toBeGreaterThan(0);
|
||||||
for (const entry of entriesWithIcons) {
|
for (const entry of entriesWithIcons) {
|
||||||
@@ -39,6 +39,7 @@ describe("ACP provider catalog", () => {
|
|||||||
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
|
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
|
||||||
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
|
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
|
||||||
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
||||||
|
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
|
||||||
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
|
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { Alert, Pressable, Text, TextInput, View } from "react-native";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native";
|
import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native";
|
||||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Alert, Pressable, Text, View } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||||
|
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||||
|
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||||
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
isHostRuntimeConnected,
|
isHostRuntimeConnected,
|
||||||
|
useHostMutations,
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHostRuntimeIsConnected,
|
useHostRuntimeIsConnected,
|
||||||
useHostRuntimeSnapshot,
|
useHostRuntimeSnapshot,
|
||||||
useHostMutations,
|
|
||||||
useHosts,
|
useHosts,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
|
||||||
import { settingsStyles } from "@/styles/settings";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
|
||||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
|
||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
|
||||||
import { ProvidersSection } from "@/screens/settings/providers-section";
|
import { ProvidersSection } from "@/screens/settings/providers-section";
|
||||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { settingsStyles } from "@/styles/settings";
|
||||||
|
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||||
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||||
|
|
||||||
const RESTART_CONFIRMATION_MESSAGE =
|
const RESTART_CONFIRMATION_MESSAGE =
|
||||||
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
|
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
|
||||||
@@ -68,7 +69,6 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
|||||||
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RENAME_HOST_HEADER: SheetHeader = { title: "Rename host" };
|
|
||||||
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
||||||
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
|
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
|
||||||
|
|
||||||
@@ -178,64 +178,23 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { renameHost } = useHostMutations();
|
const { renameHost } = useHostMutations();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [draftLabel, setDraftLabel] = useState(host.label ?? "");
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const inputRef = useRef<TextInput>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSubmit = useCallback(
|
||||||
setDraftLabel(host.label ?? "");
|
async (value: string) => {
|
||||||
}, [host.serverId, host.label]);
|
const nextLabel = value.trim();
|
||||||
|
if (nextLabel === host.label.trim()) return;
|
||||||
useEffect(() => {
|
|
||||||
if (isEditing) {
|
|
||||||
const timeout = setTimeout(() => inputRef.current?.focus(), 50);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [isEditing]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
const nextLabel = draftLabel.trim();
|
|
||||||
if (!nextLabel) {
|
|
||||||
Alert.alert("Label required", "Enter a label for this host.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isSaving) return;
|
|
||||||
if (nextLabel === host.label.trim()) {
|
|
||||||
setIsEditing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setIsSaving(true);
|
|
||||||
await renameHost(host.serverId, nextLabel);
|
await renameHost(host.serverId, nextLabel);
|
||||||
setIsEditing(false);
|
},
|
||||||
} catch (error) {
|
[host.label, host.serverId, renameHost],
|
||||||
console.error("[HostPage] Failed to rename host", error);
|
);
|
||||||
Alert.alert("Error", "Unable to save host");
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}, [draftLabel, host.label, host.serverId, isSaving, renameHost]);
|
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const openEditor = useCallback(() => setIsEditing(true), []);
|
||||||
if (isSaving) return;
|
const closeEditor = useCallback(() => setIsEditing(false), []);
|
||||||
setDraftLabel(host.label ?? "");
|
|
||||||
setIsEditing(false);
|
|
||||||
}, [host.label, isSaving]);
|
|
||||||
|
|
||||||
const handleStartEdit = useCallback(() => {
|
|
||||||
setDraftLabel(host.label ?? "");
|
|
||||||
setIsEditing(true);
|
|
||||||
}, [host.label]);
|
|
||||||
|
|
||||||
const handleSavePress = useCallback(() => {
|
|
||||||
void handleSave();
|
|
||||||
}, [handleSave]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleStartEdit}
|
onPress={openEditor}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.identityEditButton}
|
style={styles.identityEditButton}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -245,48 +204,16 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
|||||||
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<AdaptiveModalSheet
|
<AdaptiveRenameModal
|
||||||
visible={isEditing}
|
visible={isEditing}
|
||||||
onClose={handleCancel}
|
title="Rename host"
|
||||||
header={RENAME_HOST_HEADER}
|
initialValue={host.label}
|
||||||
|
placeholder="My Host"
|
||||||
|
submitLabel="Save"
|
||||||
|
onClose={closeEditor}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
testID="host-page-rename-modal"
|
testID="host-page-rename-modal"
|
||||||
>
|
/>
|
||||||
<View style={styles.renameBody}>
|
|
||||||
<TextInput
|
|
||||||
ref={inputRef}
|
|
||||||
value={draftLabel}
|
|
||||||
onChangeText={setDraftLabel}
|
|
||||||
placeholder="My Host"
|
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
editable={!isSaving}
|
|
||||||
onSubmitEditing={handleSavePress}
|
|
||||||
style={styles.renameInput}
|
|
||||||
testID="host-page-label-input"
|
|
||||||
/>
|
|
||||||
<View style={styles.renameActions}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
style={FLEX_1_STYLE}
|
|
||||||
onPress={handleCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
style={FLEX_1_STYLE}
|
|
||||||
onPress={handleSavePress}
|
|
||||||
disabled={isSaving}
|
|
||||||
testID="host-page-label-save"
|
|
||||||
>
|
|
||||||
{isSaving ? "Saving..." : "Save"}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</AdaptiveModalSheet>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -836,25 +763,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
renameBody: {
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
paddingBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
renameInput: {
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
},
|
|
||||||
renameActions: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const FLEX_1_STYLE = { flex: 1 };
|
const FLEX_1_STYLE = { flex: 1 };
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { type QueryClient } from "@tanstack/react-query";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
|
||||||
|
interface RenamingTabState {
|
||||||
|
kind: "terminal" | "agent";
|
||||||
|
id: string;
|
||||||
|
currentTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseWorkspaceTabRenameInput {
|
||||||
|
client: DaemonClient | null;
|
||||||
|
normalizedServerId: string;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
terminalsData: ListTerminalsResponse["payload"] | undefined;
|
||||||
|
terminalsQueryKey: readonly unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseWorkspaceTabRenameResult {
|
||||||
|
renamingTab: RenamingTabState | null;
|
||||||
|
handleRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||||
|
handleRenameModalSubmit: (nextTitle: string) => Promise<void>;
|
||||||
|
handleRenameModalClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceTabRename(
|
||||||
|
input: UseWorkspaceTabRenameInput,
|
||||||
|
): UseWorkspaceTabRenameResult {
|
||||||
|
const { client, normalizedServerId, queryClient, terminalsData, terminalsQueryKey } = input;
|
||||||
|
const [renamingTab, setRenamingTab] = useState<RenamingTabState | null>(null);
|
||||||
|
|
||||||
|
const handleRenameTab = useCallback(
|
||||||
|
(tab: WorkspaceTabDescriptor) => {
|
||||||
|
if (tab.target.kind === "terminal") {
|
||||||
|
const { terminalId } = tab.target;
|
||||||
|
const terminal = terminalsData?.terminals.find((entry) => entry.id === terminalId) ?? null;
|
||||||
|
const currentTitle = terminal?.title ?? terminal?.name ?? "";
|
||||||
|
setRenamingTab({ kind: "terminal", id: terminalId, currentTitle });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tab.target.kind === "agent") {
|
||||||
|
const { agentId } = tab.target;
|
||||||
|
const agent =
|
||||||
|
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
|
||||||
|
const currentTitle = agent?.title ?? "";
|
||||||
|
setRenamingTab({ kind: "agent", id: agentId, currentTitle });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[normalizedServerId, terminalsData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenameModalSubmit = useCallback(
|
||||||
|
async (nextTitle: string) => {
|
||||||
|
if (!renamingTab) return;
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Host is not connected");
|
||||||
|
}
|
||||||
|
const trimmed = nextTitle.trim();
|
||||||
|
if (renamingTab.kind === "terminal") {
|
||||||
|
const result = await client.renameTerminal({
|
||||||
|
terminalId: renamingTab.id,
|
||||||
|
title: trimmed,
|
||||||
|
});
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error ?? "Failed to rename terminal");
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.updateAgent(renamingTab.id, { name: trimmed });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["sidebarAgentsList", normalizedServerId],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["allAgents", normalizedServerId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenameModalClose = useCallback(() => {
|
||||||
|
setRenamingTab(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
renamingTab,
|
||||||
|
handleRenameTab,
|
||||||
|
handleRenameModalSubmit,
|
||||||
|
handleRenameModalClose,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceTabRenameModalProps {
|
||||||
|
renamingTab: RenamingTabState | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (nextTitle: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceTabRenameModal({
|
||||||
|
renamingTab,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: WorkspaceTabRenameModalProps) {
|
||||||
|
const title = renamingTab?.kind === "terminal" ? "Rename terminal" : "Rename agent";
|
||||||
|
const initialValue = renamingTab?.currentTitle ?? "";
|
||||||
|
const testID = renamingTab
|
||||||
|
? `workspace-tab-rename-modal-${renamingTab.kind}-${renamingTab.id}`
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<AdaptiveRenameModal
|
||||||
|
visible={renamingTab !== null}
|
||||||
|
title={title}
|
||||||
|
initialValue={initialValue}
|
||||||
|
submitLabel="Rename"
|
||||||
|
maxLength={200}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
testID={testID}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
ArrowRightToLine,
|
ArrowRightToLine,
|
||||||
Columns2,
|
Columns2,
|
||||||
Copy,
|
Copy,
|
||||||
|
Pencil,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Rows2,
|
Rows2,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -71,6 +72,7 @@ const ThemedRotateCw = withUnistyles(RotateCw);
|
|||||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||||
const ThemedCopyX = withUnistyles(CopyX);
|
const ThemedCopyX = withUnistyles(CopyX);
|
||||||
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||||
const ThemedGlobe = withUnistyles(Globe);
|
const ThemedGlobe = withUnistyles(Globe);
|
||||||
@@ -101,6 +103,8 @@ function TabContextMenuItem({
|
|||||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||||
case "copy-x":
|
case "copy-x":
|
||||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||||
|
case "pencil":
|
||||||
|
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
||||||
case "x":
|
case "x":
|
||||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||||
default:
|
default:
|
||||||
@@ -150,6 +154,7 @@ interface WorkspaceDesktopTabsRowProps {
|
|||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||||
|
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
@@ -467,6 +472,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -599,6 +605,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
onReloadAgent={onReloadAgent}
|
onReloadAgent={onReloadAgent}
|
||||||
|
onRenameTab={onRenameTab}
|
||||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||||
onCloseTabsToRight={onCloseTabsToRight}
|
onCloseTabsToRight={onCloseTabsToRight}
|
||||||
onCloseOtherTabs={onCloseOtherTabs}
|
onCloseOtherTabs={onCloseOtherTabs}
|
||||||
@@ -630,6 +637,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onNavigateTab,
|
onNavigateTab,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
setHoveredCloseTabKey,
|
setHoveredCloseTabKey,
|
||||||
setHoveredTabKey,
|
setHoveredTabKey,
|
||||||
tabDropPreviewIndex,
|
tabDropPreviewIndex,
|
||||||
@@ -791,6 +799,7 @@ function ResolvedDesktopTabChip({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
onCloseOtherTabs,
|
onCloseOtherTabs,
|
||||||
@@ -815,6 +824,7 @@ function ResolvedDesktopTabChip({
|
|||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||||
|
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||||
@@ -838,6 +848,7 @@ function ResolvedDesktopTabChip({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsToLeft,
|
onCloseTabsToLeft,
|
||||||
onCloseTabsToRight,
|
onCloseTabsToRight,
|
||||||
@@ -853,6 +864,7 @@ function ResolvedDesktopTabChip({
|
|||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
tabCount,
|
tabCount,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter, type Href } from "expo-router";
|
import { useRouter, type Href } from "expo-router";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Import as ImportIcon,
|
Import as ImportIcon,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
|
Pencil,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Settings,
|
Settings,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
@@ -109,6 +110,10 @@ import {
|
|||||||
WorkspaceTabOptionRow,
|
WorkspaceTabOptionRow,
|
||||||
type WorkspaceTabPresentation,
|
type WorkspaceTabPresentation,
|
||||||
} from "@/screens/workspace/workspace-tab-presentation";
|
} from "@/screens/workspace/workspace-tab-presentation";
|
||||||
|
import {
|
||||||
|
useWorkspaceTabRename,
|
||||||
|
WorkspaceTabRenameModal,
|
||||||
|
} from "@/screens/workspace/use-workspace-tab-rename";
|
||||||
import {
|
import {
|
||||||
WorkspaceDesktopTabsRow,
|
WorkspaceDesktopTabsRow,
|
||||||
type WorkspaceDesktopTabRowItem,
|
type WorkspaceDesktopTabRowItem,
|
||||||
@@ -181,6 +186,7 @@ const ThemedRotateCw = withUnistyles(RotateCw);
|
|||||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||||
const ThemedCopyX = withUnistyles(CopyX);
|
const ThemedCopyX = withUnistyles(CopyX);
|
||||||
|
const ThemedPencil = withUnistyles(Pencil);
|
||||||
const ThemedX = withUnistyles(X);
|
const ThemedX = withUnistyles(X);
|
||||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||||
@@ -297,6 +303,7 @@ interface MobileWorkspaceTabSwitcherProps {
|
|||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||||
|
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||||
@@ -436,6 +443,8 @@ function MobileTabDropdownMenuItem({
|
|||||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||||
case "copy-x":
|
case "copy-x":
|
||||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||||
|
case "pencil":
|
||||||
|
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
||||||
case "x":
|
case "x":
|
||||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||||
default:
|
default:
|
||||||
@@ -473,6 +482,7 @@ function MobileWorkspaceTabOption({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsAbove,
|
onCloseTabsAbove,
|
||||||
onCloseTabsBelow,
|
onCloseTabsBelow,
|
||||||
@@ -489,6 +499,7 @@ function MobileWorkspaceTabOption({
|
|||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||||
|
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||||
@@ -504,6 +515,7 @@ function MobileWorkspaceTabOption({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsBefore: onCloseTabsAbove,
|
onCloseTabsBefore: onCloseTabsAbove,
|
||||||
onCloseTabsAfter: onCloseTabsBelow,
|
onCloseTabsAfter: onCloseTabsBelow,
|
||||||
@@ -558,6 +570,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsAbove,
|
onCloseTabsAbove,
|
||||||
onCloseTabsBelow,
|
onCloseTabsBelow,
|
||||||
@@ -611,6 +624,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
|||||||
onCopyResumeCommand={onCopyResumeCommand}
|
onCopyResumeCommand={onCopyResumeCommand}
|
||||||
onCopyAgentId={onCopyAgentId}
|
onCopyAgentId={onCopyAgentId}
|
||||||
onReloadAgent={onReloadAgent}
|
onReloadAgent={onReloadAgent}
|
||||||
|
onRenameTab={onRenameTab}
|
||||||
onCloseTab={onCloseTab}
|
onCloseTab={onCloseTab}
|
||||||
onCloseTabsAbove={onCloseTabsAbove}
|
onCloseTabsAbove={onCloseTabsAbove}
|
||||||
onCloseTabsBelow={onCloseTabsBelow}
|
onCloseTabsBelow={onCloseTabsBelow}
|
||||||
@@ -627,6 +641,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsAbove,
|
onCloseTabsAbove,
|
||||||
onCloseTabsBelow,
|
onCloseTabsBelow,
|
||||||
@@ -1532,6 +1547,7 @@ function WorkspaceScreenContent({
|
|||||||
openWorkspaceTabFocused,
|
openWorkspaceTabFocused,
|
||||||
toast,
|
toast,
|
||||||
});
|
});
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const {
|
const {
|
||||||
createMutation: createTerminalMutation,
|
createMutation: createTerminalMutation,
|
||||||
createTerminal,
|
createTerminal,
|
||||||
@@ -1543,6 +1559,7 @@ function WorkspaceScreenContent({
|
|||||||
liveTerminalIds,
|
liveTerminalIds,
|
||||||
pendingCreateInput: pendingTerminalCreateInput,
|
pendingCreateInput: pendingTerminalCreateInput,
|
||||||
query: terminalsQuery,
|
query: terminalsQuery,
|
||||||
|
queryKey: terminalsQueryKey,
|
||||||
removeTerminalFromCache,
|
removeTerminalFromCache,
|
||||||
standaloneTerminalIds,
|
standaloneTerminalIds,
|
||||||
terminals,
|
terminals,
|
||||||
@@ -2125,6 +2142,14 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||||
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
|
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
|
||||||
|
const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } =
|
||||||
|
useWorkspaceTabRename({
|
||||||
|
client,
|
||||||
|
normalizedServerId,
|
||||||
|
queryClient,
|
||||||
|
terminalsData: terminalsQuery.data,
|
||||||
|
terminalsQueryKey,
|
||||||
|
});
|
||||||
|
|
||||||
const tabByKey = useMemo(() => {
|
const tabByKey = useMemo(() => {
|
||||||
const map = new Map<string, WorkspaceTabDescriptor>();
|
const map = new Map<string, WorkspaceTabDescriptor>();
|
||||||
@@ -3152,6 +3177,7 @@ function WorkspaceScreenContent({
|
|||||||
onCopyResumeCommand={handleCopyResumeCommand}
|
onCopyResumeCommand={handleCopyResumeCommand}
|
||||||
onCopyAgentId={handleCopyAgentId}
|
onCopyAgentId={handleCopyAgentId}
|
||||||
onReloadAgent={handleReloadAgent}
|
onReloadAgent={handleReloadAgent}
|
||||||
|
onRenameTab={handleRenameTab}
|
||||||
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
||||||
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
||||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||||
@@ -3186,6 +3212,7 @@ function WorkspaceScreenContent({
|
|||||||
handleCopyResumeCommand,
|
handleCopyResumeCommand,
|
||||||
handleCopyAgentId,
|
handleCopyAgentId,
|
||||||
handleReloadAgent,
|
handleReloadAgent,
|
||||||
|
handleRenameTab,
|
||||||
handleCloseTabsToLeftInPane,
|
handleCloseTabsToLeftInPane,
|
||||||
handleCloseTabsToRightInPane,
|
handleCloseTabsToRightInPane,
|
||||||
handleCloseOtherTabsInPane,
|
handleCloseOtherTabsInPane,
|
||||||
@@ -3268,6 +3295,7 @@ function WorkspaceScreenContent({
|
|||||||
onCopyResumeCommand={handleCopyResumeCommand}
|
onCopyResumeCommand={handleCopyResumeCommand}
|
||||||
onCopyAgentId={handleCopyAgentId}
|
onCopyAgentId={handleCopyAgentId}
|
||||||
onReloadAgent={handleReloadAgent}
|
onReloadAgent={handleReloadAgent}
|
||||||
|
onRenameTab={handleRenameTab}
|
||||||
onCloseTab={handleCloseTabById}
|
onCloseTab={handleCloseTabById}
|
||||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||||
onCloseTabsBelow={handleCloseTabsToRight}
|
onCloseTabsBelow={handleCloseTabsToRight}
|
||||||
@@ -3289,6 +3317,7 @@ function WorkspaceScreenContent({
|
|||||||
onCopyResumeCommand={handleCopyResumeCommand}
|
onCopyResumeCommand={handleCopyResumeCommand}
|
||||||
onCopyAgentId={handleCopyAgentId}
|
onCopyAgentId={handleCopyAgentId}
|
||||||
onReloadAgent={handleReloadAgent}
|
onReloadAgent={handleReloadAgent}
|
||||||
|
onRenameTab={handleRenameTab}
|
||||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||||
onCloseTabsToRight={handleCloseTabsToRight}
|
onCloseTabsToRight={handleCloseTabsToRight}
|
||||||
onCloseOtherTabs={handleCloseOtherTabs}
|
onCloseOtherTabs={handleCloseOtherTabs}
|
||||||
@@ -3337,6 +3366,11 @@ function WorkspaceScreenContent({
|
|||||||
onClose={closeImportSheet}
|
onClose={closeImportSheet}
|
||||||
onImportedAgent={handleImportedAgent}
|
onImportedAgent={handleImportedAgent}
|
||||||
/>
|
/>
|
||||||
|
<WorkspaceTabRenameModal
|
||||||
|
renamingTab={renamingTab}
|
||||||
|
onSubmit={handleRenameModalSubmit}
|
||||||
|
onClose={handleRenameModalClose}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</WorkspaceFocusProvider>
|
</WorkspaceFocusProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
const onCopyResumeCommand = vi.fn();
|
const onCopyResumeCommand = vi.fn();
|
||||||
const onCopyAgentId = vi.fn();
|
const onCopyAgentId = vi.fn();
|
||||||
const onReloadAgent = vi.fn();
|
const onReloadAgent = vi.fn();
|
||||||
|
const onRenameTab = vi.fn();
|
||||||
const onCloseTab = vi.fn();
|
const onCloseTab = vi.fn();
|
||||||
const onCloseTabsBefore = vi.fn();
|
const onCloseTabsBefore = vi.fn();
|
||||||
const onCloseTabsAfter = vi.fn();
|
const onCloseTabsAfter = vi.fn();
|
||||||
@@ -30,6 +31,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsBefore,
|
onCloseTabsBefore,
|
||||||
onCloseTabsAfter,
|
onCloseTabsAfter,
|
||||||
@@ -39,6 +41,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||||
"Copy resume command",
|
"Copy resume command",
|
||||||
"Copy agent id",
|
"Copy agent id",
|
||||||
|
"Rename",
|
||||||
"Close to the left",
|
"Close to the left",
|
||||||
"Close to the right",
|
"Close to the right",
|
||||||
"Close other tabs",
|
"Close other tabs",
|
||||||
@@ -57,6 +60,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
onCopyResumeCommand: vi.fn(),
|
onCopyResumeCommand: vi.fn(),
|
||||||
onCopyAgentId: vi.fn(),
|
onCopyAgentId: vi.fn(),
|
||||||
onReloadAgent: vi.fn(),
|
onReloadAgent: vi.fn(),
|
||||||
|
onRenameTab: vi.fn(),
|
||||||
onCloseTab: vi.fn(),
|
onCloseTab: vi.fn(),
|
||||||
onCloseTabsBefore: vi.fn(),
|
onCloseTabsBefore: vi.fn(),
|
||||||
onCloseTabsAfter: vi.fn(),
|
onCloseTabsAfter: vi.fn(),
|
||||||
@@ -66,6 +70,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||||
"Copy resume command",
|
"Copy resume command",
|
||||||
"Copy agent id",
|
"Copy agent id",
|
||||||
|
"Rename",
|
||||||
"Close tabs above",
|
"Close tabs above",
|
||||||
"Close tabs below",
|
"Close tabs below",
|
||||||
"Close other tabs",
|
"Close other tabs",
|
||||||
@@ -74,7 +79,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits agent copy actions for non-agent tabs", () => {
|
it("omits agent copy actions and rename for draft tabs", () => {
|
||||||
const entries = buildWorkspaceTabMenuEntries({
|
const entries = buildWorkspaceTabMenuEntries({
|
||||||
surface: "mobile",
|
surface: "mobile",
|
||||||
tab: {
|
tab: {
|
||||||
@@ -89,6 +94,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
onCopyResumeCommand: vi.fn(),
|
onCopyResumeCommand: vi.fn(),
|
||||||
onCopyAgentId: vi.fn(),
|
onCopyAgentId: vi.fn(),
|
||||||
onReloadAgent: vi.fn(),
|
onReloadAgent: vi.fn(),
|
||||||
|
onRenameTab: vi.fn(),
|
||||||
onCloseTab: vi.fn(),
|
onCloseTab: vi.fn(),
|
||||||
onCloseTabsBefore: vi.fn(),
|
onCloseTabsBefore: vi.fn(),
|
||||||
onCloseTabsAfter: vi.fn(),
|
onCloseTabsAfter: vi.fn(),
|
||||||
@@ -101,6 +107,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe(
|
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Rename")).toBe(false);
|
||||||
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
|
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,6 +121,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
onCopyResumeCommand: vi.fn(),
|
onCopyResumeCommand: vi.fn(),
|
||||||
onCopyAgentId: vi.fn(),
|
onCopyAgentId: vi.fn(),
|
||||||
onReloadAgent: vi.fn(),
|
onReloadAgent: vi.fn(),
|
||||||
|
onRenameTab: vi.fn(),
|
||||||
onCloseTab: vi.fn(),
|
onCloseTab: vi.fn(),
|
||||||
onCloseTabsBefore: vi.fn(),
|
onCloseTabsBefore: vi.fn(),
|
||||||
onCloseTabsAfter: vi.fn(),
|
onCloseTabsAfter: vi.fn(),
|
||||||
@@ -128,4 +136,128 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("invokes onRenameTab when the rename entry is selected for agent tabs", () => {
|
||||||
|
const onRenameTab = vi.fn();
|
||||||
|
const tab = createAgentTab();
|
||||||
|
const entries = buildWorkspaceTabMenuEntries({
|
||||||
|
surface: "desktop",
|
||||||
|
tab,
|
||||||
|
index: 0,
|
||||||
|
tabCount: 1,
|
||||||
|
menuTestIDBase: "workspace-tab-context-agent_123",
|
||||||
|
onCopyResumeCommand: vi.fn(),
|
||||||
|
onCopyAgentId: vi.fn(),
|
||||||
|
onReloadAgent: vi.fn(),
|
||||||
|
onRenameTab,
|
||||||
|
onCloseTab: vi.fn(),
|
||||||
|
onCloseTabsBefore: vi.fn(),
|
||||||
|
onCloseTabsAfter: vi.fn(),
|
||||||
|
onCloseOtherTabs: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename");
|
||||||
|
if (!renameEntry || renameEntry.kind !== "item") {
|
||||||
|
throw new Error("Rename entry missing");
|
||||||
|
}
|
||||||
|
renameEntry.onSelect();
|
||||||
|
|
||||||
|
expect(onRenameTab).toHaveBeenCalledWith(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes rename as the first entry for terminal tabs", () => {
|
||||||
|
const onRenameTab = vi.fn();
|
||||||
|
const terminalTab: WorkspaceTabDescriptor = {
|
||||||
|
key: "terminal_abc",
|
||||||
|
tabId: "terminal_abc",
|
||||||
|
kind: "terminal",
|
||||||
|
target: { kind: "terminal", terminalId: "terminal-abc" },
|
||||||
|
};
|
||||||
|
const entries = buildWorkspaceTabMenuEntries({
|
||||||
|
surface: "desktop",
|
||||||
|
tab: terminalTab,
|
||||||
|
index: 0,
|
||||||
|
tabCount: 1,
|
||||||
|
menuTestIDBase: "workspace-tab-context-terminal_abc",
|
||||||
|
onCopyResumeCommand: vi.fn(),
|
||||||
|
onCopyAgentId: vi.fn(),
|
||||||
|
onReloadAgent: vi.fn(),
|
||||||
|
onRenameTab,
|
||||||
|
onCloseTab: vi.fn(),
|
||||||
|
onCloseTabsBefore: vi.fn(),
|
||||||
|
onCloseTabsAfter: vi.fn(),
|
||||||
|
onCloseOtherTabs: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = entries.filter((entry) => entry.kind === "item").map((entry) => entry.label);
|
||||||
|
expect(labels[0]).toBe("Rename");
|
||||||
|
expect(labels).not.toContain("Copy resume command");
|
||||||
|
expect(labels).not.toContain("Copy agent id");
|
||||||
|
expect(labels).not.toContain("Reload agent");
|
||||||
|
|
||||||
|
const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename");
|
||||||
|
if (!renameEntry || renameEntry.kind !== "item") {
|
||||||
|
throw new Error("Rename entry missing");
|
||||||
|
}
|
||||||
|
renameEntry.onSelect();
|
||||||
|
expect(onRenameTab).toHaveBeenCalledWith(terminalTab);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the same rename entry shape for agent and terminal tabs", () => {
|
||||||
|
const terminalTab: WorkspaceTabDescriptor = {
|
||||||
|
key: "terminal_abc",
|
||||||
|
tabId: "terminal_abc",
|
||||||
|
kind: "terminal",
|
||||||
|
target: { kind: "terminal", terminalId: "terminal-abc" },
|
||||||
|
};
|
||||||
|
const menuTestIDBase = "workspace-tab-context";
|
||||||
|
const sharedInput = {
|
||||||
|
surface: "desktop" as const,
|
||||||
|
index: 0,
|
||||||
|
tabCount: 1,
|
||||||
|
menuTestIDBase,
|
||||||
|
onCopyResumeCommand: vi.fn(),
|
||||||
|
onCopyAgentId: vi.fn(),
|
||||||
|
onReloadAgent: vi.fn(),
|
||||||
|
onRenameTab: vi.fn(),
|
||||||
|
onCloseTab: vi.fn(),
|
||||||
|
onCloseTabsBefore: vi.fn(),
|
||||||
|
onCloseTabsAfter: vi.fn(),
|
||||||
|
onCloseOtherTabs: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: createAgentTab() });
|
||||||
|
const terminalEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: terminalTab });
|
||||||
|
|
||||||
|
const agentRename = agentEntries.find(
|
||||||
|
(entry) => entry.kind === "item" && entry.key === "rename",
|
||||||
|
);
|
||||||
|
const terminalRename = terminalEntries.find(
|
||||||
|
(entry) => entry.kind === "item" && entry.key === "rename",
|
||||||
|
);
|
||||||
|
if (!agentRename || agentRename.kind !== "item") throw new Error("Agent rename missing");
|
||||||
|
if (!terminalRename || terminalRename.kind !== "item")
|
||||||
|
throw new Error("Terminal rename missing");
|
||||||
|
|
||||||
|
expect({
|
||||||
|
key: agentRename.key,
|
||||||
|
label: agentRename.label,
|
||||||
|
icon: agentRename.icon,
|
||||||
|
testID: agentRename.testID,
|
||||||
|
}).toEqual({
|
||||||
|
key: terminalRename.key,
|
||||||
|
label: terminalRename.label,
|
||||||
|
icon: terminalRename.icon,
|
||||||
|
testID: terminalRename.testID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentSeparator = agentEntries
|
||||||
|
.slice(agentEntries.indexOf(agentRename) + 1)
|
||||||
|
.find((entry) => entry.kind === "separator");
|
||||||
|
const terminalSeparator = terminalEntries
|
||||||
|
.slice(terminalEntries.indexOf(terminalRename) + 1)
|
||||||
|
.find((entry) => entry.kind === "separator");
|
||||||
|
expect(agentSeparator?.key).toBe("rename-separator");
|
||||||
|
expect(terminalSeparator?.key).toBe("rename-separator");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ export type WorkspaceTabMenuEntry =
|
|||||||
kind: "item";
|
kind: "item";
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon?: "copy" | "rotate-cw" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
|
icon?:
|
||||||
|
| "copy"
|
||||||
|
| "rotate-cw"
|
||||||
|
| "arrow-left-to-line"
|
||||||
|
| "arrow-right-to-line"
|
||||||
|
| "copy-x"
|
||||||
|
| "pencil"
|
||||||
|
| "x";
|
||||||
hint?: string;
|
hint?: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -30,6 +37,7 @@ interface BuildWorkspaceTabMenuEntriesInput {
|
|||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||||
|
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
|
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
|
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
|
||||||
@@ -43,6 +51,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
|
|||||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||||
|
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||||
@@ -102,6 +111,7 @@ export function buildWorkspaceTabMenuEntries(
|
|||||||
onCopyResumeCommand,
|
onCopyResumeCommand,
|
||||||
onCopyAgentId,
|
onCopyAgentId,
|
||||||
onReloadAgent,
|
onReloadAgent,
|
||||||
|
onRenameTab,
|
||||||
onCloseTab,
|
onCloseTab,
|
||||||
onCloseTabsBefore,
|
onCloseTabsBefore,
|
||||||
onCloseTabsAfter,
|
onCloseTabsAfter,
|
||||||
@@ -135,9 +145,22 @@ export function buildWorkspaceTabMenuEntries(
|
|||||||
void onCopyAgentId(agentId);
|
void onCopyAgentId(agentId);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab.target.kind === "agent" || tab.target.kind === "terminal") {
|
||||||
|
entries.push({
|
||||||
|
kind: "item",
|
||||||
|
key: "rename",
|
||||||
|
label: "Rename",
|
||||||
|
icon: "pencil",
|
||||||
|
testID: `${menuTestIDBase}-rename`,
|
||||||
|
onSelect: () => {
|
||||||
|
onRenameTab(tab);
|
||||||
|
},
|
||||||
|
});
|
||||||
entries.push({
|
entries.push({
|
||||||
kind: "separator",
|
kind: "separator",
|
||||||
key: "copy-separator",
|
key: "rename-separator",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +240,7 @@ export function buildWorkspaceDesktopTabActions(
|
|||||||
onCopyResumeCommand: input.onCopyResumeCommand,
|
onCopyResumeCommand: input.onCopyResumeCommand,
|
||||||
onCopyAgentId: input.onCopyAgentId,
|
onCopyAgentId: input.onCopyAgentId,
|
||||||
onReloadAgent: input.onReloadAgent,
|
onReloadAgent: input.onReloadAgent,
|
||||||
|
onRenameTab: input.onRenameTab,
|
||||||
onCloseTab: input.onCloseTab,
|
onCloseTab: input.onCloseTab,
|
||||||
onCloseTabsBefore: input.onCloseTabsToLeft,
|
onCloseTabsBefore: input.onCloseTabsToLeft,
|
||||||
onCloseTabsAfter: input.onCloseTabsToRight,
|
onCloseTabsAfter: input.onCloseTabsToRight,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@server/shared/terminal-input-mode";
|
} from "@server/shared/terminal-input-mode";
|
||||||
import {
|
import {
|
||||||
type PendingTerminalModifiers,
|
type PendingTerminalModifiers,
|
||||||
|
isAppleHandheldPlatform,
|
||||||
isTerminalModifierDomKey,
|
isTerminalModifierDomKey,
|
||||||
mergeTerminalModifiers,
|
mergeTerminalModifiers,
|
||||||
normalizeDomTerminalKey,
|
normalizeDomTerminalKey,
|
||||||
@@ -84,6 +85,14 @@ const isMac =
|
|||||||
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
||||||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
|
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
|
||||||
|
|
||||||
|
const isAppleHandheld =
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
isAppleHandheldPlatform({
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
platform: (navigator as Navigator & { platform?: string }).platform,
|
||||||
|
maxTouchPoints: navigator.maxTouchPoints,
|
||||||
|
});
|
||||||
|
|
||||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||||
@@ -365,6 +374,7 @@ export class TerminalEmulatorRuntime {
|
|||||||
metaKey: event.metaKey,
|
metaKey: event.metaKey,
|
||||||
pendingModifiers: this.pendingModifiers,
|
pendingModifiers: this.pendingModifiers,
|
||||||
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
||||||
|
isAppleHandheld,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
hasPendingTerminalModifiers,
|
hasPendingTerminalModifiers,
|
||||||
|
isAppleHandheldPlatform,
|
||||||
isTerminalModifierDomKey,
|
isTerminalModifierDomKey,
|
||||||
mapTerminalDataToKey,
|
mapTerminalDataToKey,
|
||||||
mergeTerminalModifiers,
|
mergeTerminalModifiers,
|
||||||
@@ -10,6 +11,13 @@ import {
|
|||||||
shouldInterceptDomTerminalKey,
|
shouldInterceptDomTerminalKey,
|
||||||
} from "./terminal-keys";
|
} from "./terminal-keys";
|
||||||
|
|
||||||
|
const IPAD_UA =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15";
|
||||||
|
const MAC_UA =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
|
||||||
|
const IPHONE_UA =
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148";
|
||||||
|
|
||||||
describe("terminal key helpers", () => {
|
describe("terminal key helpers", () => {
|
||||||
it("normalizes supported DOM keys", () => {
|
it("normalizes supported DOM keys", () => {
|
||||||
expect(normalizeDomTerminalKey("Esc")).toBe("Escape");
|
expect(normalizeDomTerminalKey("Esc")).toBe("Escape");
|
||||||
@@ -157,6 +165,140 @@ describe("terminal key helpers", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("intercepts plain Ctrl+C on iPad so xterm's keyCode-13 quirk never reaches the PTY (#1049)", () => {
|
||||||
|
// See COMPAT(xterm-ipad-ctrl-c) in terminal-keys.ts.
|
||||||
|
expect(
|
||||||
|
shouldInterceptDomTerminalKey({
|
||||||
|
key: "c",
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
isAppleHandheld: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
// Uppercase variant in case Caps Lock is on.
|
||||||
|
expect(
|
||||||
|
shouldInterceptDomTerminalKey({
|
||||||
|
key: "C",
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
isAppleHandheld: true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not intercept other Ctrl+letter combos on iPad (xterm handles them correctly)", () => {
|
||||||
|
for (const key of ["b", "d", "z", "a", "r", "l"]) {
|
||||||
|
expect(
|
||||||
|
shouldInterceptDomTerminalKey({
|
||||||
|
key,
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
isAppleHandheld: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not intercept Ctrl+C on real macOS / Windows / Linux", () => {
|
||||||
|
expect(
|
||||||
|
shouldInterceptDomTerminalKey({
|
||||||
|
key: "c",
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
isAppleHandheld: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not intercept Cmd+C on iPad (Cmd-based shortcuts stay with the OS)", () => {
|
||||||
|
expect(
|
||||||
|
shouldInterceptDomTerminalKey({
|
||||||
|
key: "c",
|
||||||
|
ctrlKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: true,
|
||||||
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
isAppleHandheld: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not intercept Ctrl+Shift+C / Ctrl+Alt+C on iPad", () => {
|
||||||
|
// Only bare Ctrl+C is affected by the WebKit quirk; modified variants stay with xterm.
|
||||||
|
expect(
|
||||||
|
shouldInterceptDomTerminalKey({
|
||||||
|
key: "c",
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
altKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
isAppleHandheld: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldInterceptDomTerminalKey({
|
||||||
|
key: "c",
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: true,
|
||||||
|
metaKey: false,
|
||||||
|
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||||
|
isAppleHandheld: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects iPad masquerading as macOS via maxTouchPoints", () => {
|
||||||
|
expect(
|
||||||
|
isAppleHandheldPlatform({ userAgent: IPAD_UA, platform: "MacIntel", maxTouchPoints: 5 }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects iPhone/iPod by UA", () => {
|
||||||
|
expect(
|
||||||
|
isAppleHandheldPlatform({ userAgent: IPHONE_UA, platform: "iPhone", maxTouchPoints: 5 }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag real macOS desktop as a handheld", () => {
|
||||||
|
expect(
|
||||||
|
isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 0 }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag macOS when maxTouchPoints == 1 (some trackpad contexts)", () => {
|
||||||
|
expect(
|
||||||
|
isAppleHandheldPlatform({ userAgent: MAC_UA, platform: "MacIntel", maxTouchPoints: 1 }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates null/undefined navigator-style inputs", () => {
|
||||||
|
expect(isAppleHandheldPlatform({ userAgent: null, platform: null, maxTouchPoints: null })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isAppleHandheldPlatform({
|
||||||
|
userAgent: undefined,
|
||||||
|
platform: undefined,
|
||||||
|
maxTouchPoints: undefined,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("detects pending modifier state", () => {
|
it("detects pending modifier state", () => {
|
||||||
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
|
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
|
||||||
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
|
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
|
||||||
|
|||||||
@@ -82,6 +82,27 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers)
|
|||||||
return modifiers.ctrl || modifiers.shift || modifiers.alt;
|
return modifiers.ctrl || modifiers.shift || modifiers.alt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AppleHandheldDetectionInput {
|
||||||
|
userAgent: string | null | undefined;
|
||||||
|
platform: string | null | undefined;
|
||||||
|
maxTouchPoints: number | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish
|
||||||
|
// iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS.
|
||||||
|
export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean {
|
||||||
|
const userAgent = input.userAgent ?? "";
|
||||||
|
const platform = input.platform ?? "";
|
||||||
|
const touchPoints = input.maxTouchPoints ?? 0;
|
||||||
|
if (/iPad|iPhone|iPod/.test(userAgent)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/Mac/i.test(platform) && touchPoints > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldInterceptDomTerminalKey(args: {
|
export function shouldInterceptDomTerminalKey(args: {
|
||||||
key: string;
|
key: string;
|
||||||
ctrlKey: boolean;
|
ctrlKey: boolean;
|
||||||
@@ -90,6 +111,7 @@ export function shouldInterceptDomTerminalKey(args: {
|
|||||||
metaKey: boolean;
|
metaKey: boolean;
|
||||||
pendingModifiers: PendingTerminalModifiers;
|
pendingModifiers: PendingTerminalModifiers;
|
||||||
enhancedInputActive?: boolean;
|
enhancedInputActive?: boolean;
|
||||||
|
isAppleHandheld?: boolean;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -97,6 +119,19 @@ export function shouldInterceptDomTerminalKey(args: {
|
|||||||
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
|
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
|
||||||
return Boolean(args.enhancedInputActive);
|
return Boolean(args.enhancedInputActive);
|
||||||
}
|
}
|
||||||
|
// COMPAT(xterm-ipad-ctrl-c): WebKit sends keyCode=13 for hardware-kbd Ctrl+C on iPad, so
|
||||||
|
// xterm.js emits \r instead of \x03. Upstream: xtermjs/xterm.js#5721, targeting xterm.js 7.0.0.
|
||||||
|
// Drop this block and the isAppleHandheld plumbing once @xterm/xterm is bumped past it.
|
||||||
|
if (
|
||||||
|
args.isAppleHandheld &&
|
||||||
|
args.ctrlKey &&
|
||||||
|
!args.metaKey &&
|
||||||
|
!args.altKey &&
|
||||||
|
!args.shiftKey &&
|
||||||
|
(args.key === "c" || args.key === "C")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -392,9 +392,15 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
|
|||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...envWithHome(options.home),
|
...envWithHome(options.home),
|
||||||
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
|
||||||
|
// This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available.
|
||||||
PASEO_LISTEN: undefined,
|
PASEO_LISTEN: undefined,
|
||||||
PASEO_HOSTNAMES: undefined,
|
PASEO_HOSTNAMES: undefined,
|
||||||
PASEO_ALLOWED_HOSTS: undefined,
|
PASEO_ALLOWED_HOSTS: undefined,
|
||||||
|
PASEO_RELAY_ENABLED: undefined,
|
||||||
|
PASEO_RELAY_ENDPOINT: undefined,
|
||||||
|
PASEO_RELAY_PUBLIC_ENDPOINT: undefined,
|
||||||
|
PASEO_RELAY_USE_TLS: undefined,
|
||||||
|
PASEO_RELAY_PUBLIC_USE_TLS: undefined,
|
||||||
};
|
};
|
||||||
const home = resolvePaseoHome(env);
|
const home = resolvePaseoHome(env);
|
||||||
const config = loadConfig(home, { env });
|
const config = loadConfig(home, { env });
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server";
|
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server";
|
||||||
|
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||||
|
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||||
import { addJsonOption } from "../../utils/command-options.js";
|
import { addJsonOption } from "../../utils/command-options.js";
|
||||||
|
|
||||||
interface PairOptions {
|
interface PairOptions {
|
||||||
@@ -22,6 +24,35 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paseoHome = resolvePaseoHome();
|
const paseoHome = resolvePaseoHome();
|
||||||
|
const state = resolveLocalDaemonState({ home: paseoHome });
|
||||||
|
const host = resolveTcpHostFromListen(state.listen);
|
||||||
|
|
||||||
|
// Try to get the pairing offer from the running daemon first.
|
||||||
|
if (host) {
|
||||||
|
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||||
|
if (client) {
|
||||||
|
const supportsDaemonStatusRpc =
|
||||||
|
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||||
|
if (supportsDaemonStatusRpc) {
|
||||||
|
try {
|
||||||
|
const offer = await client.getDaemonPairingOffer();
|
||||||
|
await client.close().catch(() => {});
|
||||||
|
outputPairingResult(
|
||||||
|
{ relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while
|
||||||
|
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||||
|
// v0.1.76; pairing should come from daemon.get_pairing_offer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to local pairing offer generation.
|
||||||
const config = loadConfig(paseoHome);
|
const config = loadConfig(paseoHome);
|
||||||
const pairing = await generateLocalPairingOffer({
|
const pairing = await generateLocalPairingOffer({
|
||||||
paseoHome,
|
paseoHome,
|
||||||
@@ -34,6 +65,13 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
|||||||
includeQr: true,
|
includeQr: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
outputPairingResult(pairing, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputPairingResult(
|
||||||
|
pairing: { relayEnabled: boolean; url: string | null; qr: string | null },
|
||||||
|
options: PairOptions,
|
||||||
|
): void {
|
||||||
if (!pairing.relayEnabled || !pairing.url) {
|
if (!pairing.relayEnabled || !pairing.url) {
|
||||||
console.error(chalk.red("Relay pairing is disabled for this daemon config."));
|
console.error(chalk.red("Relay pairing is disabled for this daemon config."));
|
||||||
console.error(chalk.yellow("Enable relay and run this command again."));
|
console.error(chalk.yellow("Enable relay and run this command again."));
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ProviderBinaryStatus {
|
|||||||
label: string;
|
label: string;
|
||||||
path: string | null;
|
path: string | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
|
source?: "daemon" | "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DaemonStatus {
|
interface DaemonStatus {
|
||||||
@@ -99,7 +100,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
|
|||||||
return "red";
|
return "red";
|
||||||
}
|
}
|
||||||
if (item.key.startsWith(" ")) {
|
if (item.key.startsWith(" ")) {
|
||||||
if (item.value === "not found") return "red";
|
if (item.value === "not found" || item.value === "not found (daemon)") return "red";
|
||||||
if (item.value.endsWith("(--version failed)")) return "yellow";
|
if (item.value.endsWith("(--version failed)")) return "yellow";
|
||||||
return "green";
|
return "green";
|
||||||
}
|
}
|
||||||
@@ -149,7 +150,13 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
|||||||
rows.push({ key: "", value: "" });
|
rows.push({ key: "", value: "" });
|
||||||
rows.push({ key: "Providers", value: "" });
|
rows.push({ key: "Providers", value: "" });
|
||||||
for (const provider of status.providers) {
|
for (const provider of status.providers) {
|
||||||
if (!provider.path) {
|
if (provider.source === "daemon") {
|
||||||
|
if (!provider.path) {
|
||||||
|
rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" });
|
||||||
|
} else {
|
||||||
|
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` });
|
||||||
|
}
|
||||||
|
} else if (!provider.path) {
|
||||||
rows.push({ key: ` ${provider.label}`, value: "not found" });
|
rows.push({ key: ` ${provider.label}`, value: "not found" });
|
||||||
} else if (!provider.version) {
|
} else if (!provider.version) {
|
||||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
|
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
|
||||||
@@ -210,6 +217,7 @@ interface DaemonProbeResult {
|
|||||||
runningAgents?: number;
|
runningAgents?: number;
|
||||||
idleAgents?: number;
|
idleAgents?: number;
|
||||||
daemonNodeOverride?: string;
|
daemonNodeOverride?: string;
|
||||||
|
daemonProviders?: ProviderBinaryStatus[];
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,12 +239,32 @@ async function probeDaemonOverWebsocket(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
|
const daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
|
||||||
|
const supportsDaemonStatusRpc =
|
||||||
|
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||||
try {
|
try {
|
||||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||||
const runningAgents = agents.filter((a) => a.status === "running").length;
|
const runningAgents = agents.filter((a) => a.status === "running").length;
|
||||||
const idleAgents = agents.filter((a) => a.status === "idle").length;
|
const idleAgents = agents.filter((a) => a.status === "idle").length;
|
||||||
|
|
||||||
|
let daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||||
|
if (supportsDaemonStatusRpc) {
|
||||||
|
try {
|
||||||
|
const statusPayload = await client.getDaemonStatus();
|
||||||
|
const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label]));
|
||||||
|
daemonProviders = statusPayload.providers.map((p) => ({
|
||||||
|
label: labelMap.get(p.provider) ?? p.provider,
|
||||||
|
path: p.available ? "available" : null,
|
||||||
|
version: p.available ? null : (p.error ?? null),
|
||||||
|
source: "daemon" as const,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// COMPAT(daemon-rpc-rollout): fall back to CLI-side provider resolution while
|
||||||
|
// old daemons lack daemonStatusRpc. Remove once the daemon floor is past
|
||||||
|
// v0.1.76; status should come from daemon.get_status.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.running) {
|
if (!state.running) {
|
||||||
return {
|
return {
|
||||||
connectedDaemon: "reachable",
|
connectedDaemon: "reachable",
|
||||||
@@ -244,6 +272,7 @@ async function probeDaemonOverWebsocket(args: {
|
|||||||
runningAgents,
|
runningAgents,
|
||||||
idleAgents,
|
idleAgents,
|
||||||
daemonNodeOverride: "unknown (API reachable, PID unresolved)",
|
daemonNodeOverride: "unknown (API reachable, PID unresolved)",
|
||||||
|
daemonProviders,
|
||||||
note: state.pidInfo
|
note: state.pidInfo
|
||||||
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
|
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
|
||||||
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
|
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
|
||||||
@@ -255,6 +284,7 @@ async function probeDaemonOverWebsocket(args: {
|
|||||||
daemonVersion,
|
daemonVersion,
|
||||||
runningAgents,
|
runningAgents,
|
||||||
idleAgents,
|
idleAgents,
|
||||||
|
daemonProviders,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
@@ -278,6 +308,7 @@ interface ProbeMergeState {
|
|||||||
daemonVersion: string | null;
|
daemonVersion: string | null;
|
||||||
runningAgents: number | null;
|
runningAgents: number | null;
|
||||||
idleAgents: number | null;
|
idleAgents: number | null;
|
||||||
|
daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||||
note: string | undefined;
|
note: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +321,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "prob
|
|||||||
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
|
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
|
||||||
runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents,
|
runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents,
|
||||||
idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents,
|
idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents,
|
||||||
|
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
|
||||||
note: probe.note ? appendNote(input.note, probe.note) : input.note,
|
note: probe.note ? appendNote(input.note, probe.note) : input.note,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -338,6 +370,7 @@ export async function runStatusCommand(
|
|||||||
let runningAgents: number | null = null;
|
let runningAgents: number | null = null;
|
||||||
let idleAgents: number | null = null;
|
let idleAgents: number | null = null;
|
||||||
let daemonVersion: string | null = null;
|
let daemonVersion: string | null = null;
|
||||||
|
let daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||||
let note: string | undefined;
|
let note: string | undefined;
|
||||||
|
|
||||||
if (!state.running && state.stalePidFile && state.pidInfo) {
|
if (!state.running && state.stalePidFile && state.pidInfo) {
|
||||||
@@ -347,17 +380,26 @@ export async function runStatusCommand(
|
|||||||
|
|
||||||
if (host) {
|
if (host) {
|
||||||
const probe = await probeDaemonOverWebsocket({ host, state });
|
const probe = await probeDaemonOverWebsocket({ host, state });
|
||||||
({ connectedDaemon, localDaemon, daemonNode, daemonVersion, runningAgents, idleAgents, note } =
|
({
|
||||||
applyProbeToStatus({
|
connectedDaemon,
|
||||||
probe,
|
localDaemon,
|
||||||
connectedDaemon,
|
daemonNode,
|
||||||
localDaemon,
|
daemonVersion,
|
||||||
daemonNode,
|
runningAgents,
|
||||||
daemonVersion,
|
idleAgents,
|
||||||
runningAgents,
|
daemonProviders,
|
||||||
idleAgents,
|
note,
|
||||||
note,
|
} = applyProbeToStatus({
|
||||||
}));
|
probe,
|
||||||
|
connectedDaemon,
|
||||||
|
localDaemon,
|
||||||
|
daemonNode,
|
||||||
|
daemonVersion,
|
||||||
|
runningAgents,
|
||||||
|
idleAgents,
|
||||||
|
daemonProviders,
|
||||||
|
note,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
|
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
|
||||||
}
|
}
|
||||||
@@ -370,7 +412,7 @@ export async function runStatusCommand(
|
|||||||
note = appendNote(note, serverIdResult.error);
|
note = appendNote(note, serverIdResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const providers = await checkProviderBinaries();
|
const providers = daemonProviders ?? (await checkProviderBinaries());
|
||||||
|
|
||||||
const daemonStatus: DaemonStatus = {
|
const daemonStatus: DaemonStatus = {
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import {
|
|||||||
getMainWindowChromeOptions,
|
getMainWindowChromeOptions,
|
||||||
getWindowBackgroundColor,
|
getWindowBackgroundColor,
|
||||||
resolveSystemWindowTheme,
|
resolveSystemWindowTheme,
|
||||||
setupDarwinPaintRefresh,
|
|
||||||
setupWindowResizeEvents,
|
setupWindowResizeEvents,
|
||||||
setupDefaultContextMenu,
|
setupDefaultContextMenu,
|
||||||
setupDragDropPrevention,
|
setupDragDropPrevention,
|
||||||
buildStandardContextMenuItems,
|
buildStandardContextMenuItems,
|
||||||
} from "./window/window-manager.js";
|
} from "./window/window-manager.js";
|
||||||
|
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
|
||||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||||
import {
|
import {
|
||||||
registerNotificationHandlers,
|
registerNotificationHandlers,
|
||||||
@@ -398,7 +398,7 @@ async function createMainWindow(): Promise<void> {
|
|||||||
app.dock?.setBadge(devWorktreeName);
|
app.dock?.setBadge(devWorktreeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupDarwinPaintRefresh(mainWindow);
|
setupDarwinCompositorWatchdog(mainWindow);
|
||||||
setupWindowResizeEvents(mainWindow);
|
setupWindowResizeEvents(mainWindow);
|
||||||
setupDefaultContextMenu(mainWindow);
|
setupDefaultContextMenu(mainWindow);
|
||||||
setupDragDropPrevention(mainWindow);
|
setupDragDropPrevention(mainWindow);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { shouldRecoverFromFrameStall } from ".";
|
||||||
|
|
||||||
|
describe("compositor-watchdog", () => {
|
||||||
|
describe("shouldRecoverFromFrameStall", () => {
|
||||||
|
const recoverable = {
|
||||||
|
stalledChecks: 3,
|
||||||
|
recovering: false,
|
||||||
|
msSinceLastRecovery: 120_000,
|
||||||
|
consecutiveRecoveries: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("recovers once the stall threshold is reached", () => {
|
||||||
|
expect(shouldRecoverFromFrameStall(recoverable)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits until the stall threshold is reached", () => {
|
||||||
|
expect(shouldRecoverFromFrameStall({ ...recoverable, stalledChecks: 2 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not recover while a recovery is already in progress", () => {
|
||||||
|
expect(shouldRecoverFromFrameStall({ ...recoverable, recovering: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects the cooldown between recoveries", () => {
|
||||||
|
expect(shouldRecoverFromFrameStall({ ...recoverable, msSinceLastRecovery: 30_000 })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops recovering after the consecutive-recovery cap", () => {
|
||||||
|
expect(shouldRecoverFromFrameStall({ ...recoverable, consecutiveRecoveries: 3 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { app, type BrowserWindow, powerMonitor } from "electron";
|
||||||
|
|
||||||
|
// COMPAT(darwinCompositorWatchdog): added in v0.1.78, target removal after
|
||||||
|
// 2026-11-19. Workaround for Electron/Chromium macOS display-sleep compositor
|
||||||
|
// stalls; re-test when Electron/Chromium is upgraded.
|
||||||
|
|
||||||
|
// How often the main process probes the renderer for frame production.
|
||||||
|
const FRAME_PROBE_INTERVAL_MS = 2000;
|
||||||
|
// A probed frame must arrive within this window or the probe counts as stalled.
|
||||||
|
const FRAME_PROBE_DEADLINE_MS = 300;
|
||||||
|
// Consecutive stalled probes before the watchdog restarts the GPU process (~6 s).
|
||||||
|
const FRAME_STALL_CHECKS_TO_RECOVER = 3;
|
||||||
|
// Minimum gap between GPU-process restarts.
|
||||||
|
const COMPOSITOR_RECOVERY_COOLDOWN_MS = 60_000;
|
||||||
|
// Grace period for Chromium to relaunch the GPU process before probing resumes.
|
||||||
|
const GPU_RELAUNCH_GRACE_MS = 5_000;
|
||||||
|
// Stop restarting the GPU process after this many tries without frames returning.
|
||||||
|
const MAX_CONSECUTIVE_RECOVERIES = 3;
|
||||||
|
|
||||||
|
// Resolves { producedFrame, visibilityState } for the renderer. The frame is
|
||||||
|
// requested with requestAnimationFrame; setTimeout (not vsync-driven) bounds the
|
||||||
|
// wait so the probe always resolves even when frame production has stopped.
|
||||||
|
const FRAME_PROBE_SOURCE = `new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (producedFrame) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve({ producedFrame, visibilityState: document.visibilityState });
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => finish(true));
|
||||||
|
setTimeout(() => finish(false), ${FRAME_PROBE_DEADLINE_MS});
|
||||||
|
})`;
|
||||||
|
|
||||||
|
interface FrameStallState {
|
||||||
|
stalledChecks: number;
|
||||||
|
recovering: boolean;
|
||||||
|
msSinceLastRecovery: number;
|
||||||
|
consecutiveRecoveries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRecoverFromFrameStall(state: FrameStallState): boolean {
|
||||||
|
return (
|
||||||
|
state.stalledChecks >= FRAME_STALL_CHECKS_TO_RECOVER &&
|
||||||
|
!state.recovering &&
|
||||||
|
state.msSinceLastRecovery >= COMPOSITOR_RECOVERY_COOLDOWN_MS &&
|
||||||
|
state.consecutiveRecoveries < MAX_CONSECUTIVE_RECOVERIES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGpuProcessPid(): number | null {
|
||||||
|
for (const metric of app.getAppMetrics()) {
|
||||||
|
if (metric.type === "GPU") {
|
||||||
|
return metric.pid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS display sleep can leave Chromium's GPU-process display link (the vsync
|
||||||
|
// source that drives frame production) stuck on a stale display. The compositor
|
||||||
|
// then stops producing frames and the window looks frozen: unresponsive to
|
||||||
|
// clicks and keys even though the renderer and every process stay alive. This
|
||||||
|
// watchdog polls the renderer for frame production and, on a sustained stall,
|
||||||
|
// restarts the GPU process so Chromium rebuilds the display link.
|
||||||
|
export function setupDarwinCompositorWatchdog(win: BrowserWindow): void {
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep producing frames while occluded so the probe is not fooled by throttling.
|
||||||
|
win.webContents.setBackgroundThrottling(false);
|
||||||
|
|
||||||
|
let stalledChecks = 0;
|
||||||
|
let recovering = false;
|
||||||
|
let lastRecoveryAt = 0;
|
||||||
|
let consecutiveRecoveries = 0;
|
||||||
|
let screenLocked = false;
|
||||||
|
|
||||||
|
const recoverCompositor = async () => {
|
||||||
|
recovering = true;
|
||||||
|
lastRecoveryAt = Date.now();
|
||||||
|
consecutiveRecoveries += 1;
|
||||||
|
stalledChecks = 0;
|
||||||
|
const gpuPid = findGpuProcessPid();
|
||||||
|
console.warn(
|
||||||
|
`[compositor-watchdog] Desktop window stopped producing frames; restarting GPU process ` +
|
||||||
|
`(pid=${gpuPid ?? "unknown"}, attempt ${consecutiveRecoveries}) to recover`,
|
||||||
|
);
|
||||||
|
if (gpuPid !== null) {
|
||||||
|
try {
|
||||||
|
process.kill(gpuPid, "SIGKILL");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[compositor-watchdog] Could not restart GPU process", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, GPU_RELAUNCH_GRACE_MS));
|
||||||
|
recovering = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const probeFrameProduction = async () => {
|
||||||
|
if (win.isDestroyed() || recovering) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A freeze is only meaningful, and only distinguishable from a normal idle
|
||||||
|
// window, while the window is actually on screen. A locked screen, a
|
||||||
|
// minimized window, or a hidden one legitimately stops producing frames.
|
||||||
|
if (screenLocked || !win.isVisible() || win.isMinimized()) {
|
||||||
|
stalledChecks = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: { producedFrame?: unknown; visibilityState?: unknown } | null;
|
||||||
|
try {
|
||||||
|
result = await win.webContents.executeJavaScript(FRAME_PROBE_SOURCE);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result || result.visibilityState !== "visible") {
|
||||||
|
stalledChecks = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.producedFrame === true) {
|
||||||
|
stalledChecks = 0;
|
||||||
|
consecutiveRecoveries = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stalledChecks += 1;
|
||||||
|
if (
|
||||||
|
shouldRecoverFromFrameStall({
|
||||||
|
stalledChecks,
|
||||||
|
recovering,
|
||||||
|
msSinceLastRecovery: Date.now() - lastRecoveryAt,
|
||||||
|
consecutiveRecoveries,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
void recoverCompositor();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const probeTimer = setInterval(() => void probeFrameProduction(), FRAME_PROBE_INTERVAL_MS);
|
||||||
|
const handleScreenLocked = () => {
|
||||||
|
screenLocked = true;
|
||||||
|
stalledChecks = 0;
|
||||||
|
};
|
||||||
|
const handleScreenUnlocked = () => {
|
||||||
|
screenLocked = false;
|
||||||
|
stalledChecks = 0;
|
||||||
|
};
|
||||||
|
powerMonitor.on("lock-screen", handleScreenLocked);
|
||||||
|
powerMonitor.on("unlock-screen", handleScreenUnlocked);
|
||||||
|
|
||||||
|
win.once("closed", () => {
|
||||||
|
clearInterval(probeTimer);
|
||||||
|
powerMonitor.off("lock-screen", handleScreenLocked);
|
||||||
|
powerMonitor.off("unlock-screen", handleScreenUnlocked);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -229,59 +229,6 @@ export function setupWindowResizeEvents(win: BrowserWindow): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshChromiumSurface(win: BrowserWindow): void {
|
|
||||||
if (win.isDestroyed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
win.webContents.invalidate();
|
|
||||||
if (win.isMaximized() || win.isFullScreen()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [width, height] = win.getSize();
|
|
||||||
win.setSize(width + 1, height);
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!win.isDestroyed()) {
|
|
||||||
win.setSize(width, height);
|
|
||||||
}
|
|
||||||
}, 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupDarwinPaintRefresh(win: BrowserWindow): void {
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
win.webContents.setBackgroundThrottling(false);
|
|
||||||
|
|
||||||
const requestSurfaceRefresh = () => {
|
|
||||||
if (!win.isDestroyed()) {
|
|
||||||
win.webContents.invalidate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleChildProcessGone = (
|
|
||||||
_event: Electron.Event,
|
|
||||||
details: { type?: string; reason?: string },
|
|
||||||
) => {
|
|
||||||
if (details.type !== "GPU") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn("[window] GPU process gone:", details.reason);
|
|
||||||
refreshChromiumSurface(win);
|
|
||||||
};
|
|
||||||
|
|
||||||
win.on("restore", requestSurfaceRefresh);
|
|
||||||
win.on("show", requestSurfaceRefresh);
|
|
||||||
app.on("child-process-gone", handleChildProcessGone);
|
|
||||||
win.once("closed", () => {
|
|
||||||
win.off("restore", requestSurfaceRefresh);
|
|
||||||
win.off("show", requestSurfaceRefresh);
|
|
||||||
app.off("child-process-gone", handleChildProcessGone);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildStandardContextMenuItems(
|
export function buildStandardContextMenuItems(
|
||||||
contents: WebContents,
|
contents: WebContents,
|
||||||
params: Electron.ContextMenuParams,
|
params: Electron.ContextMenuParams,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ function createMockTransportPair(): [Transport, Transport] {
|
|||||||
return [transportA, transportB];
|
return [transportA, transportB];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForAsyncDelivery(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
describe("EncryptedChannel", () => {
|
describe("EncryptedChannel", () => {
|
||||||
it("establishes encrypted channel between daemon and client", async () => {
|
it("establishes encrypted channel between daemon and client", async () => {
|
||||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||||
@@ -96,7 +100,7 @@ describe("EncryptedChannel", () => {
|
|||||||
await clientChannel.send("Second message from client");
|
await clientChannel.send("Second message from client");
|
||||||
|
|
||||||
// Wait for async delivery
|
// Wait for async delivery
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await waitForAsyncDelivery();
|
||||||
|
|
||||||
expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]);
|
expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]);
|
||||||
expect(clientMessages).toEqual(["Hello from daemon"]);
|
expect(clientMessages).toEqual(["Hello from daemon"]);
|
||||||
@@ -194,4 +198,75 @@ describe("EncryptedChannel", () => {
|
|||||||
|
|
||||||
await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message");
|
await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts duplicate hello from the same client without re-keying", async () => {
|
||||||
|
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||||
|
|
||||||
|
const daemonKeyPair = generateKeyPair();
|
||||||
|
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
|
||||||
|
const daemonMessages: (string | ArrayBuffer)[] = [];
|
||||||
|
|
||||||
|
let clientOpenedResolve: (() => void) | null = null;
|
||||||
|
const clientOpened = new Promise<void>((resolve) => {
|
||||||
|
clientOpenedResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair, {
|
||||||
|
onmessage: (data) => daemonMessages.push(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientChannel = await createClientChannel(clientTransport, daemonPubKeyB64, {
|
||||||
|
onopen: () => clientOpenedResolve?.(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await daemonChannelPromise;
|
||||||
|
await clientOpened;
|
||||||
|
|
||||||
|
const firstHello = (clientTransport.send as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||||
|
([data]) => typeof data === "string" && data.includes('"type":"e2ee_hello"'),
|
||||||
|
)?.[0];
|
||||||
|
expect(typeof firstHello).toBe("string");
|
||||||
|
|
||||||
|
daemonTransport.onmessage?.(firstHello as string);
|
||||||
|
await waitForAsyncDelivery();
|
||||||
|
|
||||||
|
expect(daemonTransport.close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await clientChannel.send("still encrypted with original key");
|
||||||
|
await waitForAsyncDelivery();
|
||||||
|
|
||||||
|
expect(daemonMessages).toEqual(["still encrypted with original key"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes an open daemon channel when a different client key sends hello", async () => {
|
||||||
|
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||||
|
|
||||||
|
const daemonKeyPair = generateKeyPair();
|
||||||
|
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
|
||||||
|
|
||||||
|
let clientOpenedResolve: (() => void) | null = null;
|
||||||
|
const clientOpened = new Promise<void>((resolve) => {
|
||||||
|
clientOpenedResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
|
||||||
|
|
||||||
|
await createClientChannel(clientTransport, daemonPubKeyB64, {
|
||||||
|
onopen: () => clientOpenedResolve?.(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await daemonChannelPromise;
|
||||||
|
await clientOpened;
|
||||||
|
|
||||||
|
const attackerKeyPair = generateKeyPair();
|
||||||
|
const attackerHello = JSON.stringify({
|
||||||
|
type: "e2ee_hello",
|
||||||
|
key: exportPublicKey(attackerKeyPair.publicKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
daemonTransport.onmessage?.(attackerHello);
|
||||||
|
await waitForAsyncDelivery();
|
||||||
|
|
||||||
|
expect(daemonTransport.close).toHaveBeenCalledWith(1008, "E2EE re-handshake key mismatch");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ function buildInvalidHelloError(rawText: string, parsed?: unknown): Error {
|
|||||||
|
|
||||||
const HANDSHAKE_RETRY_MS = 1000;
|
const HANDSHAKE_RETRY_MS = 1000;
|
||||||
const MAX_PENDING_SENDS = 200;
|
const MAX_PENDING_SENDS = 200;
|
||||||
|
const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008;
|
||||||
|
const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch";
|
||||||
|
|
||||||
interface TimeoutWithUnref {
|
interface TimeoutWithUnref {
|
||||||
unref(): void;
|
unref(): void;
|
||||||
@@ -420,16 +422,14 @@ export class EncryptedChannel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different key implies a new client connection (common with relays
|
// A different key on an already-open encrypted channel is not an
|
||||||
// where the daemon's socket stays open while the client reconnects).
|
// authenticated reconnect. Close and require a fresh transport instead of
|
||||||
// Re-key and re-send "ready". Drop any queued sends to avoid leaking
|
// allowing the relay to switch this channel to an attacker-chosen key.
|
||||||
// messages between logical client sessions.
|
this.state = "closed";
|
||||||
this.state = "handshaking";
|
this.transport.close(
|
||||||
this.sharedKey = nextSharedKey;
|
REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE,
|
||||||
this.pendingSends = [];
|
REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON,
|
||||||
this.transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage));
|
);
|
||||||
this.state = "open";
|
|
||||||
await this.flushPendingSends();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close(code = 1000, reason = "Normal closure"): void {
|
close(code = 1000, reason = "Normal closure"): void {
|
||||||
@@ -452,8 +452,9 @@ export class EncryptedChannel {
|
|||||||
|
|
||||||
function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
if (a.byteLength !== b.byteLength) return false;
|
if (a.byteLength !== b.byteLength) return false;
|
||||||
|
let difference = 0;
|
||||||
for (let i = 0; i < a.byteLength; i += 1) {
|
for (let i = 0; i < a.byteLength; i += 1) {
|
||||||
if (a[i] !== b[i]) return false;
|
difference |= a[i] ^ b[i];
|
||||||
}
|
}
|
||||||
return true;
|
return difference === 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
"types": "./dist/server/utils/tool-call-parsers.d.ts",
|
"types": "./dist/server/utils/tool-call-parsers.d.ts",
|
||||||
"source": "./src/utils/tool-call-parsers.ts",
|
"source": "./src/utils/tool-call-parsers.ts",
|
||||||
"default": "./dist/server/utils/tool-call-parsers.js"
|
"default": "./dist/server/utils/tool-call-parsers.js"
|
||||||
|
},
|
||||||
|
"./utils/branch-slug": {
|
||||||
|
"types": "./dist/server/utils/branch-slug.d.ts",
|
||||||
|
"source": "./src/utils/branch-slug.ts",
|
||||||
|
"default": "./dist/server/utils/branch-slug.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -58,12 +63,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||||
|
"@earendil-works/pi-agent-core": "^0.75.3",
|
||||||
|
"@earendil-works/pi-ai": "^0.75.3",
|
||||||
|
"@earendil-works/pi-coding-agent": "^0.75.3",
|
||||||
"@getpaseo/highlight": "0.1.78",
|
"@getpaseo/highlight": "0.1.78",
|
||||||
"@getpaseo/relay": "0.1.78",
|
"@getpaseo/relay": "0.1.78",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
|
||||||
"@mariozechner/pi-ai": "^0.70.2",
|
|
||||||
"@mariozechner/pi-coding-agent": "^0.70.2",
|
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
"@sctg/sentencepiece-js": "^1.1.0",
|
"@sctg/sentencepiece-js": "^1.1.0",
|
||||||
|
|||||||
@@ -1750,6 +1750,119 @@ test("requests checkout pull via RPC", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renames a branch via RPC", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.renameBranch({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
branch: "feature/new-name",
|
||||||
|
requestId: "req-rename-branch",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
const request = JSON.parse(mock.sent[0]) as {
|
||||||
|
type: "session";
|
||||||
|
message: {
|
||||||
|
type: "checkout.rename_branch.request";
|
||||||
|
cwd: string;
|
||||||
|
branch: string;
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expect(request.message.type).toBe("checkout.rename_branch.request");
|
||||||
|
expect(request.message.cwd).toBe("/tmp/project");
|
||||||
|
expect(request.message.branch).toBe("feature/new-name");
|
||||||
|
expect(request.message.requestId).toBe("req-rename-branch");
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "checkout.rename_branch.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-rename-branch",
|
||||||
|
success: true,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
currentBranch: "feature/new-name",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
requestId: "req-rename-branch",
|
||||||
|
success: true,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
currentBranch: "feature/new-name",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns renameBranch business failures", async () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
const mock = createMockTransport();
|
||||||
|
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen();
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const promise = client.renameBranch({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
branch: "already-exists",
|
||||||
|
requestId: "req-rename-branch-fail",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mock.sent).toHaveLength(1);
|
||||||
|
|
||||||
|
mock.triggerMessage(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "checkout.rename_branch.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "req-rename-branch-fail",
|
||||||
|
success: false,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
currentBranch: null,
|
||||||
|
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
requestId: "req-rename-branch-fail",
|
||||||
|
success: false,
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
currentBranch: null,
|
||||||
|
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("resubscribes checkout diff streams after reconnect", async () => {
|
test("resubscribes checkout diff streams after reconnect", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
AgentCreatedStatusPayloadSchema,
|
AgentCreatedStatusPayloadSchema,
|
||||||
AgentRefreshedStatusPayloadSchema,
|
AgentRefreshedStatusPayloadSchema,
|
||||||
AgentResumedStatusPayloadSchema,
|
AgentResumedStatusPayloadSchema,
|
||||||
|
CheckoutRenameBranchResponseSchema,
|
||||||
parseServerInfoStatusPayload,
|
parseServerInfoStatusPayload,
|
||||||
|
RenameTerminalResponseSchema,
|
||||||
RestartRequestedStatusPayloadSchema,
|
RestartRequestedStatusPayloadSchema,
|
||||||
ShutdownRequestedStatusPayloadSchema,
|
ShutdownRequestedStatusPayloadSchema,
|
||||||
SessionInboundMessageSchema,
|
SessionInboundMessageSchema,
|
||||||
@@ -60,6 +62,8 @@ import type {
|
|||||||
GetProvidersSnapshotResponseMessage,
|
GetProvidersSnapshotResponseMessage,
|
||||||
RefreshProvidersSnapshotResponseMessage,
|
RefreshProvidersSnapshotResponseMessage,
|
||||||
ProviderDiagnosticResponseMessage,
|
ProviderDiagnosticResponseMessage,
|
||||||
|
DaemonGetStatusResponse,
|
||||||
|
DaemonGetPairingOfferResponse,
|
||||||
ListTerminalsResponse,
|
ListTerminalsResponse,
|
||||||
CreateTerminalResponse,
|
CreateTerminalResponse,
|
||||||
SubscribeTerminalResponse,
|
SubscribeTerminalResponse,
|
||||||
@@ -285,6 +289,7 @@ type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["pay
|
|||||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||||
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||||
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
||||||
|
export type RenameBranchResult = z.infer<typeof CheckoutRenameBranchResponseSchema>["payload"];
|
||||||
type StashSavePayload = StashSaveResponse["payload"];
|
type StashSavePayload = StashSaveResponse["payload"];
|
||||||
type StashPopPayload = StashPopResponse["payload"];
|
type StashPopPayload = StashPopResponse["payload"];
|
||||||
type StashListPayload = StashListResponse["payload"];
|
type StashListPayload = StashListResponse["payload"];
|
||||||
@@ -317,6 +322,8 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
|||||||
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
||||||
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||||
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||||
|
type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
|
||||||
|
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
|
||||||
type ReadProjectConfigPayload = Extract<
|
type ReadProjectConfigPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "read_project_config_response" }
|
{ type: "read_project_config_response" }
|
||||||
@@ -351,6 +358,7 @@ type DictationFinishAcceptedPayload = Extract<
|
|||||||
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
||||||
|
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"];
|
||||||
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
||||||
type CloseItemsPayload = CloseItemsResponse["payload"];
|
type CloseItemsPayload = CloseItemsResponse["payload"];
|
||||||
type KillTerminalPayload = KillTerminalResponse["payload"];
|
type KillTerminalPayload = KillTerminalResponse["payload"];
|
||||||
@@ -614,6 +622,16 @@ export interface UpdateScheduleOptions {
|
|||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
}
|
}
|
||||||
|
export interface RenameBranchInput {
|
||||||
|
cwd: string;
|
||||||
|
branch: string;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
|
export interface RenameTerminalInput {
|
||||||
|
terminalId: string;
|
||||||
|
title: string;
|
||||||
|
requestId?: string;
|
||||||
|
}
|
||||||
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
|
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
|
||||||
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
|
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
|
||||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||||
@@ -2929,6 +2947,19 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renameBranch(input: RenameBranchInput): Promise<RenameBranchResult> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId: input.requestId,
|
||||||
|
message: {
|
||||||
|
type: "checkout.rename_branch.request",
|
||||||
|
cwd: input.cwd,
|
||||||
|
branch: input.branch,
|
||||||
|
},
|
||||||
|
responseType: "checkout.rename_branch.response",
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async stashSave(
|
async stashSave(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
options?: { branch?: string },
|
options?: { branch?: string },
|
||||||
@@ -3298,6 +3329,28 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDaemonStatus(requestId?: string): Promise<DaemonStatusPayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "daemon.get_status.request",
|
||||||
|
},
|
||||||
|
responseType: "daemon.get_status.response",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDaemonPairingOffer(requestId?: string): Promise<DaemonPairingOfferPayload> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: {
|
||||||
|
type: "daemon.get_pairing_offer.request",
|
||||||
|
},
|
||||||
|
responseType: "daemon.get_pairing_offer.response",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async patchDaemonConfig(
|
async patchDaemonConfig(
|
||||||
config: MutableDaemonConfigPatch,
|
config: MutableDaemonConfigPatch,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
@@ -3635,6 +3688,19 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renameTerminal(input: RenameTerminalInput): Promise<RenameTerminalResult> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId: input.requestId,
|
||||||
|
message: {
|
||||||
|
type: "terminal.rename.request",
|
||||||
|
terminalId: input.terminalId,
|
||||||
|
title: input.title,
|
||||||
|
},
|
||||||
|
responseType: "terminal.rename.response",
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async subscribeTerminal(
|
async subscribeTerminal(
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import { AgentManager } from "./agent-manager.js";
|
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
|
||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
|
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
|
||||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||||
@@ -1457,6 +1457,111 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a
|
|||||||
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
|
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("setGeneratedTitleIfUnset preserves an existing user title", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-preserve-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000128",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.setTitle(snapshot.id, "User title");
|
||||||
|
await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title");
|
||||||
|
|
||||||
|
const after = await storage.get(snapshot.id);
|
||||||
|
expect(after?.title).toBe("User title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setGeneratedTitleIfUnset persists generated title when no title exists", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000129",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title");
|
||||||
|
|
||||||
|
const after = await storage.get(snapshot.id);
|
||||||
|
expect(after?.title).toBe("Generated title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setGeneratedTitleIfUnset ignores blank generated titles", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-blank-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000130",
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = await manager.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
});
|
||||||
|
const before = await storage.get(snapshot.id);
|
||||||
|
expect(before).not.toBeNull();
|
||||||
|
|
||||||
|
const stateEvents: ManagedAgent[] = [];
|
||||||
|
manager.subscribe(
|
||||||
|
(event) => {
|
||||||
|
if (event.type === "agent_state") {
|
||||||
|
stateEvents.push(event.agent);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ agentId: snapshot.id, replayState: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.setGeneratedTitleIfUnset(snapshot.id, " ");
|
||||||
|
|
||||||
|
const after = await storage.get(snapshot.id);
|
||||||
|
expect(after?.title).toBeNull();
|
||||||
|
expect(after?.updatedAt).toBe(before?.updatedAt);
|
||||||
|
expect(manager.getAgent(snapshot.id)?.updatedAt.toISOString()).toBe(before?.updatedAt);
|
||||||
|
expect(stateEvents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setGeneratedTitleIfUnset throws for an unknown agent", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-unknown-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.setGeneratedTitleIfUnset("00000000-0000-4000-8000-000000000999", "Generated title"),
|
||||||
|
).rejects.toThrow("Unknown agent '00000000-0000-4000-8000-000000000999'");
|
||||||
|
});
|
||||||
|
|
||||||
test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => {
|
test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -1228,6 +1228,23 @@ export class AgentManager {
|
|||||||
this.emitState(agent, { persist: false });
|
this.emitState(agent, { persist: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setGeneratedTitleIfUnset(agentId: string, title: string): Promise<void> {
|
||||||
|
const agent = this.requireAgent(agentId);
|
||||||
|
const normalizedTitle = title.trim();
|
||||||
|
if (!normalizedTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = this.requireRegistry();
|
||||||
|
const persisted = await registry.setGeneratedTitleIfUnset(agent.id, normalizedTitle);
|
||||||
|
if (!persisted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.updatedAt = new Date(persisted.updatedAt);
|
||||||
|
this.emitState(agent, { persist: false });
|
||||||
|
}
|
||||||
|
|
||||||
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
agent.labels = { ...agent.labels, ...labels };
|
agent.labels = { ...agent.labels, ...labels };
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export async function generateAndApplyAgentMetadata(
|
|||||||
if (needs.needsTitle && typeof result.title === "string") {
|
if (needs.needsTitle && typeof result.title === "string") {
|
||||||
const normalizedTitle = normalizeAutoTitle(result.title);
|
const normalizedTitle = normalizeAutoTitle(result.title);
|
||||||
if (normalizedTitle) {
|
if (normalizedTitle) {
|
||||||
await options.agentManager.setTitle(options.agentId, normalizedTitle);
|
await options.agentManager.setGeneratedTitleIfUnset(options.agentId, normalizedTitle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ function createDeps(
|
|||||||
|
|
||||||
describe("agent metadata generator auto-title", () => {
|
describe("agent metadata generator auto-title", () => {
|
||||||
it("caps generated auto titles at 40 characters before persisting", async () => {
|
it("caps generated auto titles at 40 characters before persisting", async () => {
|
||||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
||||||
const manager = { setTitle } as unknown as AgentManager;
|
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
||||||
const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25);
|
const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25);
|
||||||
const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable<
|
const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable<
|
||||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||||
@@ -56,13 +56,16 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
deps: createDeps(generateStructured),
|
deps: createDeps(generateStructured),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(setTitle).toHaveBeenCalledTimes(1);
|
expect(setGeneratedTitleIfUnset).toHaveBeenCalledTimes(1);
|
||||||
expect(setTitle).toHaveBeenCalledWith("agent-1", "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS));
|
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
|
||||||
|
"agent-1",
|
||||||
|
"x".repeat(MAX_AUTO_AGENT_TITLE_CHARS),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not generate an auto title when an explicit title is provided", async () => {
|
it("does not generate an auto title when an explicit title is provided", async () => {
|
||||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
||||||
const manager = { setTitle } as unknown as AgentManager;
|
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
||||||
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable<
|
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable<
|
||||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||||
>;
|
>;
|
||||||
@@ -78,12 +81,12 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(generateStructured).not.toHaveBeenCalled();
|
expect(generateStructured).not.toHaveBeenCalled();
|
||||||
expect(setTitle).not.toHaveBeenCalled();
|
expect(setGeneratedTitleIfUnset).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates titles independently from workspace branch naming", async () => {
|
it("generates titles independently from workspace branch naming", async () => {
|
||||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
||||||
const manager = { setTitle } as unknown as AgentManager;
|
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
||||||
const generateStructured = vi
|
const generateStructured = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
||||||
@@ -108,7 +111,10 @@ describe("agent metadata generator auto-title", () => {
|
|||||||
persistSession: false,
|
persistSession: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
|
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
|
||||||
|
"agent-suppressed-branch",
|
||||||
|
"Generated title",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -170,8 +176,8 @@ async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt:
|
|||||||
writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
|
writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const setTitle = vi.fn().mockResolvedValue(undefined);
|
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
|
||||||
const manager = { setTitle } as unknown as AgentManager;
|
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
|
||||||
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable<
|
||||||
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -321,6 +321,42 @@ describe("AgentStorage", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("setGeneratedTitleIfUnset aborts when a user title is already set", async () => {
|
||||||
|
const agentId = "agent-generated-title-race";
|
||||||
|
await storage.applySnapshot(createManagedAgent({ id: agentId }));
|
||||||
|
await storage.setTitle(agentId, "User title");
|
||||||
|
|
||||||
|
const result = await storage.setGeneratedTitleIfUnset(agentId, "Generated title");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
const record = await storage.get(agentId);
|
||||||
|
expect(record?.title).toBe("User title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setGeneratedTitleIfUnset with concurrent writes does not corrupt state", async () => {
|
||||||
|
const agentId = "agent-generated-title-concurrent";
|
||||||
|
await storage.applySnapshot(createManagedAgent({ id: agentId }));
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
storage.setGeneratedTitleIfUnset(agentId, "Title A"),
|
||||||
|
storage.setGeneratedTitleIfUnset(agentId, "Title B"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const record = await storage.get(agentId);
|
||||||
|
expect(["Title A", "Title B"]).toContain(record?.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setGeneratedTitleIfUnset writes the generated title only when title is empty", async () => {
|
||||||
|
const agentId = "agent-generated-title-empty";
|
||||||
|
await storage.applySnapshot(createManagedAgent({ id: agentId }));
|
||||||
|
|
||||||
|
const written = await storage.setGeneratedTitleIfUnset(agentId, "Generated title");
|
||||||
|
|
||||||
|
expect(written?.title).toBe("Generated title");
|
||||||
|
const record = await storage.get(agentId);
|
||||||
|
expect(record?.title).toBe("Generated title");
|
||||||
|
});
|
||||||
|
|
||||||
test("applySnapshot accepts explicit title overrides", async () => {
|
test("applySnapshot accepts explicit title overrides", async () => {
|
||||||
const agentId = "agent-override";
|
const agentId = "agent-override";
|
||||||
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });
|
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });
|
||||||
|
|||||||
@@ -115,44 +115,51 @@ export class AgentStorage {
|
|||||||
|
|
||||||
async upsert(record: StoredAgentRecord): Promise<void> {
|
async upsert(record: StoredAgentRecord): Promise<void> {
|
||||||
await this.load();
|
await this.load();
|
||||||
|
await this.queueRecordWrite(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private queueRecordWrite(record: StoredAgentRecord): Promise<void> {
|
||||||
const agentId = record.id;
|
const agentId = record.id;
|
||||||
const prev = this.pendingWrites.get(agentId) ?? Promise.resolve();
|
const prev = this.pendingWrites.get(agentId) ?? Promise.resolve();
|
||||||
const next = prev.then(async () => {
|
const next = prev.then(async () => {
|
||||||
if (this.deleting.has(agentId)) {
|
if (this.deleting.has(agentId)) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextPath = this.buildRecordPath(record);
|
await this.writeRecord(record);
|
||||||
const previousPath = this.pathById.get(agentId);
|
return undefined;
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(nextPath), { recursive: true });
|
|
||||||
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
|
|
||||||
this.addIndexedPath(agentId, nextPath);
|
|
||||||
|
|
||||||
if (previousPath && previousPath !== nextPath) {
|
|
||||||
try {
|
|
||||||
await fs.unlink(previousPath);
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
this.removeIndexedPath(agentId, previousPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cache.set(agentId, record);
|
|
||||||
this.pathById.set(agentId, nextPath);
|
|
||||||
return;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.pendingWrites.set(
|
const tracked = next.finally(() => {
|
||||||
agentId,
|
if (this.pendingWrites.get(agentId) === tracked) {
|
||||||
next.finally(() => {
|
this.pendingWrites.delete(agentId);
|
||||||
if (this.pendingWrites.get(agentId) === next) {
|
}
|
||||||
this.pendingWrites.delete(agentId);
|
});
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await next;
|
this.pendingWrites.set(agentId, tracked);
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeRecord(record: StoredAgentRecord): Promise<void> {
|
||||||
|
const agentId = record.id;
|
||||||
|
const nextPath = this.buildRecordPath(record);
|
||||||
|
const previousPath = this.pathById.get(agentId);
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(nextPath), { recursive: true });
|
||||||
|
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
|
||||||
|
this.addIndexedPath(agentId, nextPath);
|
||||||
|
|
||||||
|
if (previousPath && previousPath !== nextPath) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(previousPath);
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
this.removeIndexedPath(agentId, previousPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.set(agentId, record);
|
||||||
|
this.pathById.set(agentId, nextPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
beginDelete(agentId: string): void {
|
beginDelete(agentId: string): void {
|
||||||
@@ -225,6 +232,40 @@ export class AgentStorage {
|
|||||||
await this.upsert({ ...record, title });
|
await this.upsert({ ...record, title });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setGeneratedTitleIfUnset(
|
||||||
|
agentId: string,
|
||||||
|
title: string,
|
||||||
|
): Promise<StoredAgentRecord | null> {
|
||||||
|
await this.load();
|
||||||
|
await this.waitForPendingWrite(agentId);
|
||||||
|
const record = this.cache.get(agentId) ?? null;
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`Agent ${agentId} not found`);
|
||||||
|
}
|
||||||
|
if (record.title) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-drain pending writes: a concurrent setTitle may have queued between the
|
||||||
|
// first drain and here. After waiting, re-read the cache before writing.
|
||||||
|
await this.waitForPendingWrite(agentId);
|
||||||
|
const latestRecord = this.cache.get(agentId) ?? null;
|
||||||
|
if (!latestRecord) {
|
||||||
|
throw new Error(`Agent ${agentId} not found`);
|
||||||
|
}
|
||||||
|
if (latestRecord.title) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRecord = {
|
||||||
|
...latestRecord,
|
||||||
|
title,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.queueRecordWrite(nextRecord);
|
||||||
|
return nextRecord;
|
||||||
|
}
|
||||||
|
|
||||||
async flush(): Promise<void> {
|
async flush(): Promise<void> {
|
||||||
await this.load().catch(() => undefined);
|
await this.load().catch(() => undefined);
|
||||||
const writes = Array.from(this.pendingWrites.values());
|
const writes = Array.from(this.pendingWrites.values());
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<s
|
|||||||
title: "Parity agent",
|
title: "Parity agent",
|
||||||
provider: "claude/claude-test-model",
|
provider: "claude/claude-test-model",
|
||||||
initialPrompt: "say done and stop",
|
initialPrompt: "say done and stop",
|
||||||
mode: "bypassPermissions",
|
settings: { modeId: "bypassPermissions" },
|
||||||
background: true,
|
background: true,
|
||||||
...args,
|
...args,
|
||||||
});
|
});
|
||||||
@@ -240,7 +240,7 @@ beforeAll(async () => {
|
|||||||
title: "MCP parity parent",
|
title: "MCP parity parent",
|
||||||
provider: "claude/claude-test-model",
|
provider: "claude/claude-test-model",
|
||||||
initialPrompt: "say done and stop",
|
initialPrompt: "say done and stop",
|
||||||
mode: "bypassPermissions",
|
settings: { modeId: "bypassPermissions" },
|
||||||
background: true,
|
background: true,
|
||||||
});
|
});
|
||||||
parentAgentId = str(parentPayload.agentId);
|
parentAgentId = str(parentPayload.agentId);
|
||||||
@@ -339,7 +339,7 @@ describe("Suite A: Core Fixes", () => {
|
|||||||
test("create_agent accepts provider features over MCP", async () => {
|
test("create_agent accepts provider features over MCP", async () => {
|
||||||
let agentId: string | null = null;
|
let agentId: string | null = null;
|
||||||
try {
|
try {
|
||||||
agentId = await createTopLevelAgent({ features: { test_feature: true } });
|
agentId = await createTopLevelAgent({ settings: { features: { test_feature: true } } });
|
||||||
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||||
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
|
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ describe("Suite A: Core Fixes", () => {
|
|||||||
try {
|
try {
|
||||||
agentId = await createChildAgent({
|
agentId = await createChildAgent({
|
||||||
provider: "claude/claude-test-model",
|
provider: "claude/claude-test-model",
|
||||||
features: { test_feature: true },
|
settings: { features: { test_feature: true } },
|
||||||
});
|
});
|
||||||
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||||
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
|
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
|
||||||
@@ -369,14 +369,13 @@ describe("Suite A: Core Fixes", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set_agent_feature updates provider features over MCP", async () => {
|
test("update_agent updates provider features over MCP", async () => {
|
||||||
let agentId: string | null = null;
|
let agentId: string | null = null;
|
||||||
try {
|
try {
|
||||||
agentId = await createTopLevelAgent({ features: { test_feature: false } });
|
agentId = await createTopLevelAgent({ settings: { features: { test_feature: false } } });
|
||||||
const updated = await callToolStructured(topLevelClient, "set_agent_feature", {
|
const updated = await callToolStructured(topLevelClient, "update_agent", {
|
||||||
agentId,
|
agentId,
|
||||||
featureId: "test_feature",
|
settings: { features: { test_feature: true } },
|
||||||
value: true,
|
|
||||||
});
|
});
|
||||||
expect(updated.success).toBe(true);
|
expect(updated.success).toBe(true);
|
||||||
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||||
@@ -390,15 +389,18 @@ describe("Suite A: Core Fixes", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("list_provider_features returns draft provider features over MCP", async () => {
|
test("inspect_provider returns draft provider features over MCP", async () => {
|
||||||
const payload = await callToolStructured(topLevelClient, "list_provider_features", {
|
const payload = await callToolStructured(topLevelClient, "inspect_provider", {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: parentAgentCwd,
|
cwd: parentAgentCwd,
|
||||||
model: "claude-test-model",
|
settings: {
|
||||||
featureValues: { test_feature: true },
|
model: "claude-test-model",
|
||||||
|
features: { test_feature: true },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.provider).toBe("claude");
|
expect(payload.provider).toBe("claude");
|
||||||
|
expect(payload.selectedModel).toBe("claude-test-model");
|
||||||
expect(recordArr(payload.features)).toEqual(
|
expect(recordArr(payload.features)).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ function buildAgentManagerSpies() {
|
|||||||
createAgent: vi.fn(),
|
createAgent: vi.fn(),
|
||||||
waitForAgentEvent: vi.fn(),
|
waitForAgentEvent: vi.fn(),
|
||||||
recordUserMessage: vi.fn(),
|
recordUserMessage: vi.fn(),
|
||||||
setAgentMode: vi.fn(),
|
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||||
|
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||||
|
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentFeature: vi.fn().mockResolvedValue(undefined),
|
setAgentFeature: vi.fn().mockResolvedValue(undefined),
|
||||||
setLabels: vi.fn().mockResolvedValue(undefined),
|
setLabels: vi.fn().mockResolvedValue(undefined),
|
||||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -446,7 +448,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const missingTitle = await tool.inputSchema.safeParseAsync({
|
const missingTitle = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
initialPrompt: "test",
|
initialPrompt: "test",
|
||||||
});
|
});
|
||||||
@@ -455,7 +457,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const tooLong = await tool.inputSchema.safeParseAsync({
|
const tooLong = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
title: "x".repeat(61),
|
title: "x".repeat(61),
|
||||||
initialPrompt: "test",
|
initialPrompt: "test",
|
||||||
@@ -465,7 +467,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const ok = await tool.inputSchema.safeParseAsync({
|
const ok = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
initialPrompt: "test",
|
initialPrompt: "test",
|
||||||
@@ -479,7 +481,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const tool = registeredTool(server, "create_agent");
|
const tool = registeredTool(server, "create_agent");
|
||||||
const parsed = await tool.inputSchema.safeParseAsync({
|
const parsed = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
});
|
});
|
||||||
@@ -510,7 +512,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
background: true,
|
background: true,
|
||||||
features: { fast_mode: true },
|
settings: { features: { fast_mode: true } },
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||||
@@ -536,7 +538,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const missingProvider = await tool.inputSchema.safeParseAsync({
|
const missingProvider = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
initialPrompt: "test",
|
initialPrompt: "test",
|
||||||
});
|
});
|
||||||
@@ -549,7 +551,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const providerWithoutModel = await tool.inputSchema.safeParseAsync({
|
const providerWithoutModel = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
initialPrompt: "test",
|
initialPrompt: "test",
|
||||||
@@ -558,7 +560,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({
|
const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
provider: "codex/",
|
provider: "codex/",
|
||||||
initialPrompt: "test",
|
initialPrompt: "test",
|
||||||
@@ -567,7 +569,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
|
|
||||||
const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({
|
const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
provider: "/gpt-5.4",
|
provider: "/gpt-5.4",
|
||||||
initialPrompt: "test",
|
initialPrompt: "test",
|
||||||
@@ -577,7 +579,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
tool.handler({
|
tool.handler({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
title: "Short title",
|
title: "Short title",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
@@ -722,10 +724,9 @@ describe("create_agent MCP tool", () => {
|
|||||||
await tool.handler({
|
await tool.handler({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: "Config test",
|
title: "Config test",
|
||||||
mode: "auto",
|
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
thinking: "think-hard",
|
settings: { modeId: "auto", thinkingOptionId: "think-hard" },
|
||||||
labels: { source: "mcp" },
|
labels: { source: "mcp" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1269,7 +1270,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const parsed = await tool.inputSchema.safeParseAsync({
|
const parsed = await tool.inputSchema.safeParseAsync({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: "Custom provider agent",
|
title: "Custom provider agent",
|
||||||
mode: "default",
|
settings: { modeId: "default" },
|
||||||
provider: "zai/custom-model",
|
provider: "zai/custom-model",
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
});
|
});
|
||||||
@@ -1360,7 +1361,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
background: true,
|
background: true,
|
||||||
features: { fast_mode: true },
|
settings: { features: { fast_mode: true } },
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||||
@@ -1403,7 +1404,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
await tool.handler({
|
await tool.handler({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: "Injected config test",
|
title: "Injected config test",
|
||||||
mode: "auto",
|
settings: { modeId: "auto" },
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
});
|
});
|
||||||
@@ -1428,7 +1429,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: "Bad mode",
|
title: "Bad mode",
|
||||||
provider: "opencode/gpt-5.4",
|
provider: "opencode/gpt-5.4",
|
||||||
mode: "bypassPermissions",
|
settings: { modeId: "bypassPermissions" },
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
@@ -1567,7 +1568,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
await tool.handler({
|
await tool.handler({
|
||||||
title: "Child",
|
title: "Child",
|
||||||
provider: "opencode/gpt-5.4",
|
provider: "opencode/gpt-5.4",
|
||||||
mode: "build",
|
settings: { modeId: "build" },
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1579,17 +1580,31 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("set_agent_feature MCP tool", () => {
|
describe("update_agent MCP tool", () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
it("sets a provider feature on an existing agent", async () => {
|
it("does not register the replaced feature-specific MCP tool", async () => {
|
||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
const tool = registeredTool(server, "set_agent_feature");
|
|
||||||
|
expect(lookupTool(server, "set_agent_feature")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates runtime settings before metadata", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
spies.agentStorage.get.mockResolvedValue(createStoredRecord({ id: "agent-1" }));
|
||||||
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
|
const tool = registeredTool(server, "update_agent");
|
||||||
const input = {
|
const input = {
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
featureId: "fast_mode",
|
name: "Updated agent",
|
||||||
value: true,
|
labels: { role: "worker" },
|
||||||
|
settings: {
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
thinkingOptionId: "high",
|
||||||
|
features: { fast_mode: true },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||||
@@ -1597,9 +1612,39 @@ describe("set_agent_feature MCP tool", () => {
|
|||||||
|
|
||||||
const response = await tool.handler(input);
|
const response = await tool.handler(input);
|
||||||
|
|
||||||
|
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
||||||
|
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
||||||
|
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
|
||||||
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
|
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
|
||||||
|
expect(spies.agentStorage.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "agent-1",
|
||||||
|
title: "Updated agent",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(spies.agentManager.setLabels).toHaveBeenCalledWith("agent-1", { role: "worker" });
|
||||||
expect(response.structuredContent).toEqual({ success: true });
|
expect(response.structuredContent).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not update metadata when runtime settings fail", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
|
||||||
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
|
const tool = registeredTool(server, "update_agent");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tool.handler({
|
||||||
|
agentId: "agent-1",
|
||||||
|
name: "Should not persist",
|
||||||
|
labels: { role: "worker" },
|
||||||
|
settings: { features: { fast_mode: true } },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("unsupported feature");
|
||||||
|
|
||||||
|
expect(spies.agentStorage.get).not.toHaveBeenCalled();
|
||||||
|
expect(spies.agentStorage.upsert).not.toHaveBeenCalled();
|
||||||
|
expect(spies.agentManager.setLabels).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("create_schedule MCP tool", () => {
|
describe("create_schedule MCP tool", () => {
|
||||||
@@ -2033,10 +2078,17 @@ describe("provider listing MCP tool", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("model listing MCP tool", () => {
|
describe("provider MCP tools", () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
|
|
||||||
it("lists provider features for a draft agent configuration", async () => {
|
it("does not register the replaced feature-specific provider discovery MCP tool", async () => {
|
||||||
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
|
|
||||||
|
expect(lookupTool(server, "list_provider_features")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inspects provider features for a draft agent configuration", async () => {
|
||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.listDraftFeatures.mockResolvedValue([
|
spies.agentManager.listDraftFeatures.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -2046,19 +2098,29 @@ describe("model listing MCP tool", () => {
|
|||||||
value: false,
|
value: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
const providerRegistry = {
|
||||||
|
codex: createProviderDefinition({
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "OpenAI coding agent",
|
||||||
|
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
|
providerRegistry,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_provider_features");
|
const tool = registeredTool(server, "inspect_provider");
|
||||||
const input = {
|
const input = {
|
||||||
provider: "codex",
|
provider: "codex/gpt-5.4",
|
||||||
cwd: "~/repo",
|
cwd: "~/repo",
|
||||||
modeId: "full-access",
|
settings: {
|
||||||
model: "gpt-5.4",
|
modeId: "full-access",
|
||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
featureValues: { fast_mode: true },
|
features: { fast_mode: true },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||||
@@ -2076,6 +2138,12 @@ describe("model listing MCP tool", () => {
|
|||||||
});
|
});
|
||||||
expect(response.structuredContent).toEqual({
|
expect(response.structuredContent).toEqual({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "OpenAI coding agent",
|
||||||
|
enabled: true,
|
||||||
|
status: "available",
|
||||||
|
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
|
||||||
|
selectedModel: "gpt-5.4",
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
@@ -2118,6 +2186,38 @@ describe("model listing MCP tool", () => {
|
|||||||
);
|
);
|
||||||
expect(fetchModels).not.toHaveBeenCalled();
|
expect(fetchModels).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("inspect_provider rejects disabled providers without fetching models", async () => {
|
||||||
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
|
const fetchModels = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "GPT-5.4",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const providerRegistry = {
|
||||||
|
codex: createProviderDefinition({
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
enabled: false,
|
||||||
|
fetchModels,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
providerRegistry,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const tool = registeredTool(server, "inspect_provider");
|
||||||
|
|
||||||
|
await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow(
|
||||||
|
"Provider 'codex' is disabled",
|
||||||
|
);
|
||||||
|
expect(fetchModels).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("speak MCP tool", () => {
|
describe("speak MCP tool", () => {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
AgentModelSchema,
|
AgentModelSchema,
|
||||||
AgentProviderEnum,
|
AgentProviderEnum,
|
||||||
AgentStatusEnum,
|
AgentStatusEnum,
|
||||||
|
ProviderModeSchema,
|
||||||
ProviderSummarySchema,
|
ProviderSummarySchema,
|
||||||
parseDurationString,
|
parseDurationString,
|
||||||
resolveRequiredProviderModel,
|
resolveRequiredProviderModel,
|
||||||
@@ -587,6 +588,55 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
{ message: "provider must be provider/model, for example codex/gpt-5.4" },
|
{ message: "provider must be provider/model, for example codex/gpt-5.4" },
|
||||||
);
|
);
|
||||||
|
const ProviderOrProviderModelInputSchema = AgentProviderEnum.trim()
|
||||||
|
.min(1, "provider is required")
|
||||||
|
.refine(
|
||||||
|
(value) => {
|
||||||
|
if (!value.includes("/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolveRequiredProviderModel(value);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ message: "provider must be provider or provider/model, for example codex/gpt-5.4" },
|
||||||
|
);
|
||||||
|
const CreateAgentSettingsInputSchema = z
|
||||||
|
.object({
|
||||||
|
modeId: z.string().optional().describe("Session mode to configure before the first run."),
|
||||||
|
thinkingOptionId: z.string().optional().describe("Thinking option ID."),
|
||||||
|
features: z
|
||||||
|
.record(z.unknown())
|
||||||
|
.optional()
|
||||||
|
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const UpdateAgentSettingsInputSchema = z
|
||||||
|
.object({
|
||||||
|
modeId: z.string().optional().describe("Session mode ID."),
|
||||||
|
model: z.string().nullable().optional().describe("Model ID. Pass null to clear."),
|
||||||
|
thinkingOptionId: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.describe("Thinking option ID. Pass null to clear."),
|
||||||
|
features: z
|
||||||
|
.record(z.unknown())
|
||||||
|
.optional()
|
||||||
|
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
const InspectProviderSettingsInputSchema = z
|
||||||
|
.object({
|
||||||
|
modeId: z.string().optional().describe("Draft session mode ID."),
|
||||||
|
model: z.string().optional().describe("Draft model ID."),
|
||||||
|
thinkingOptionId: z.string().optional().describe("Draft thinking option ID."),
|
||||||
|
features: z.record(z.unknown()).optional().describe("Draft provider feature values."),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
const agentToAgentInputSchema = {
|
const agentToAgentInputSchema = {
|
||||||
cwd: z
|
cwd: z
|
||||||
.string()
|
.string()
|
||||||
@@ -601,23 +651,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
provider: ProviderModelInputSchema.describe(
|
provider: ProviderModelInputSchema.describe(
|
||||||
"Required provider/model pair, for example codex/gpt-5.4.",
|
"Required provider/model pair, for example codex/gpt-5.4.",
|
||||||
),
|
),
|
||||||
thinking: z.string().optional().describe("Thinking option ID"),
|
|
||||||
features: z
|
|
||||||
.record(z.unknown())
|
|
||||||
.optional()
|
|
||||||
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
|
||||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||||
|
settings: CreateAgentSettingsInputSchema.optional().describe(
|
||||||
|
"Initial runtime settings for the new agent.",
|
||||||
|
),
|
||||||
initialPrompt: z
|
initialPrompt: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, "initialPrompt is required")
|
.min(1, "initialPrompt is required")
|
||||||
.describe("Required first task to run immediately after creation."),
|
.describe("Required first task to run immediately after creation."),
|
||||||
mode: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional session mode for the new agent. Required when the new agent uses a different provider than the caller agent.",
|
|
||||||
),
|
|
||||||
background: z
|
background: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -647,21 +689,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
provider: ProviderModelInputSchema.describe(
|
provider: ProviderModelInputSchema.describe(
|
||||||
"Required provider/model pair, for example codex/gpt-5.4.",
|
"Required provider/model pair, for example codex/gpt-5.4.",
|
||||||
),
|
),
|
||||||
thinking: z.string().optional().describe("Thinking option ID"),
|
|
||||||
features: z
|
|
||||||
.record(z.unknown())
|
|
||||||
.optional()
|
|
||||||
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
|
||||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||||
|
settings: CreateAgentSettingsInputSchema.optional().describe(
|
||||||
|
"Initial runtime settings for the new agent.",
|
||||||
|
),
|
||||||
initialPrompt: z
|
initialPrompt: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, "initialPrompt is required")
|
.min(1, "initialPrompt is required")
|
||||||
.describe("Required first task to run immediately after creation."),
|
.describe("Required first task to run immediately after creation."),
|
||||||
mode: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional session mode to configure before the first run."),
|
|
||||||
worktreeName: z
|
worktreeName: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -700,13 +736,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
|
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
|
||||||
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
|
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
|
||||||
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict();
|
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict();
|
||||||
const listProviderFeaturesInputSchema = {
|
const inspectProviderInputSchema = {
|
||||||
provider: AgentProviderEnum,
|
provider: ProviderOrProviderModelInputSchema.describe(
|
||||||
cwd: z.string().describe("Working directory used to resolve provider feature availability."),
|
"Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).",
|
||||||
modeId: z.string().optional(),
|
),
|
||||||
model: z.string().optional(),
|
cwd: z
|
||||||
thinkingOptionId: z.string().optional(),
|
.string()
|
||||||
featureValues: z.record(z.unknown()).optional(),
|
.optional()
|
||||||
|
.describe("Working directory used to resolve provider feature availability."),
|
||||||
|
settings: InspectProviderSettingsInputSchema.optional().describe(
|
||||||
|
"Draft provider settings used to compute available features.",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
||||||
@@ -758,7 +798,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
background: boolean;
|
background: boolean;
|
||||||
normalizedTitle: string | null;
|
normalizedTitle: string | null;
|
||||||
model: string | undefined;
|
model: string | undefined;
|
||||||
thinking: string | undefined;
|
thinkingOptionId: string | undefined;
|
||||||
features: Record<string, unknown> | undefined;
|
features: Record<string, unknown> | undefined;
|
||||||
labels: Record<string, string> | undefined;
|
labels: Record<string, string> | undefined;
|
||||||
notifyOnFinish: boolean;
|
notifyOnFinish: boolean;
|
||||||
@@ -805,6 +845,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
throw new Error(`Parent agent ${parentAgentId} not found`);
|
throw new Error(`Parent agent ${parentAgentId} not found`);
|
||||||
}
|
}
|
||||||
const provider = resolvedProviderModel.provider;
|
const provider = resolvedProviderModel.provider;
|
||||||
|
const settings = callerArgs.settings;
|
||||||
const resolvedCwd = resolveChildAgentCwd({
|
const resolvedCwd = resolveChildAgentCwd({
|
||||||
parentCwd: parentAgent.cwd,
|
parentCwd: parentAgent.cwd,
|
||||||
requestedCwd: callerArgs.cwd,
|
requestedCwd: callerArgs.cwd,
|
||||||
@@ -812,7 +853,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
|
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
|
||||||
});
|
});
|
||||||
const resolvedMode = resolveAndValidateCreateAgentMode({
|
const resolvedMode = resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: callerArgs.mode,
|
requestedMode: settings?.modeId,
|
||||||
targetProvider: provider,
|
targetProvider: provider,
|
||||||
parent: {
|
parent: {
|
||||||
provider: parentAgent.provider,
|
provider: parentAgent.provider,
|
||||||
@@ -828,8 +869,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
background: callerArgs.background ?? false,
|
background: callerArgs.background ?? false,
|
||||||
normalizedTitle: callerArgs.title.trim(),
|
normalizedTitle: callerArgs.title.trim(),
|
||||||
model: resolvedProviderModel.model,
|
model: resolvedProviderModel.model,
|
||||||
thinking: callerArgs.thinking,
|
thinkingOptionId: settings?.thinkingOptionId,
|
||||||
features: callerArgs.features,
|
features: settings?.features,
|
||||||
labels: callerArgs.labels,
|
labels: callerArgs.labels,
|
||||||
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
|
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
|
||||||
resolvedCwd,
|
resolvedCwd,
|
||||||
@@ -843,9 +884,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
): Promise<ResolvedCreateAgentArgs> => {
|
): Promise<ResolvedCreateAgentArgs> => {
|
||||||
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||||
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
||||||
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
|
const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } =
|
||||||
|
topLevelArgs;
|
||||||
const resolvedMode = resolveAndValidateCreateAgentMode({
|
const resolvedMode = resolveAndValidateCreateAgentMode({
|
||||||
requestedMode: mode,
|
requestedMode: settings?.modeId,
|
||||||
targetProvider: resolvedProviderModel.provider,
|
targetProvider: resolvedProviderModel.provider,
|
||||||
parent: null,
|
parent: null,
|
||||||
availableModes: getAvailableModeIds(resolvedProviderModel.provider),
|
availableModes: getAvailableModeIds(resolvedProviderModel.provider),
|
||||||
@@ -902,8 +944,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
background: topLevelArgs.background ?? false,
|
background: topLevelArgs.background ?? false,
|
||||||
normalizedTitle: topLevelArgs.title.trim(),
|
normalizedTitle: topLevelArgs.title.trim(),
|
||||||
model: resolvedProviderModel.model,
|
model: resolvedProviderModel.model,
|
||||||
thinking: topLevelArgs.thinking,
|
thinkingOptionId: settings?.thinkingOptionId,
|
||||||
features: topLevelArgs.features,
|
features: settings?.features,
|
||||||
labels: topLevelArgs.labels,
|
labels: topLevelArgs.labels,
|
||||||
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
||||||
resolvedCwd,
|
resolvedCwd,
|
||||||
@@ -946,7 +988,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
background,
|
background,
|
||||||
normalizedTitle,
|
normalizedTitle,
|
||||||
model,
|
model,
|
||||||
thinking,
|
thinkingOptionId,
|
||||||
features,
|
features,
|
||||||
labels,
|
labels,
|
||||||
notifyOnFinish,
|
notifyOnFinish,
|
||||||
@@ -968,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
modeId: resolvedMode,
|
modeId: resolvedMode,
|
||||||
title: normalizedTitle ?? undefined,
|
title: normalizedTitle ?? undefined,
|
||||||
model,
|
model,
|
||||||
thinkingOptionId: thinking,
|
thinkingOptionId,
|
||||||
featureValues: features,
|
featureValues: features,
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1057,29 +1099,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"set_agent_feature",
|
|
||||||
{
|
|
||||||
title: "Set agent feature",
|
|
||||||
description: "Set a provider-specific feature on an existing agent, such as Codex fast_mode.",
|
|
||||||
inputSchema: {
|
|
||||||
agentId: z.string(),
|
|
||||||
featureId: z.string().trim().min(1),
|
|
||||||
value: z.unknown(),
|
|
||||||
},
|
|
||||||
outputSchema: {
|
|
||||||
success: z.boolean(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ agentId, featureId, value }) => {
|
|
||||||
await agentManager.setAgentFeature(agentId, featureId, value);
|
|
||||||
return {
|
|
||||||
content: [],
|
|
||||||
structuredContent: ensureValidJson({ success: true }),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"wait_for_agent",
|
"wait_for_agent",
|
||||||
{
|
{
|
||||||
@@ -1440,17 +1459,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
"update_agent",
|
"update_agent",
|
||||||
{
|
{
|
||||||
title: "Update agent",
|
title: "Update agent",
|
||||||
description: "Update an agent name and/or labels.",
|
description: "Update an agent name, labels, and/or runtime settings.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||||
|
settings: UpdateAgentSettingsInputSchema.optional().describe(
|
||||||
|
"Runtime settings to apply to the agent.",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ agentId, name, labels }) => {
|
async ({ agentId, name, labels, settings }) => {
|
||||||
|
if (settings?.modeId !== undefined) {
|
||||||
|
await agentManager.setAgentMode(agentId, settings.modeId);
|
||||||
|
}
|
||||||
|
if (settings?.model !== undefined) {
|
||||||
|
await agentManager.setAgentModel(agentId, settings.model);
|
||||||
|
}
|
||||||
|
if (settings?.thinkingOptionId !== undefined) {
|
||||||
|
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
|
||||||
|
}
|
||||||
|
if (settings?.features) {
|
||||||
|
for (const [featureId, value] of Object.entries(settings.features)) {
|
||||||
|
await agentManager.setAgentFeature(agentId, featureId, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const trimmedName = name?.trim();
|
const trimmedName = name?.trim();
|
||||||
if (trimmedName) {
|
if (trimmedName) {
|
||||||
const record = await agentStorage.get(agentId);
|
const record = await agentStorage.get(agentId);
|
||||||
@@ -2022,30 +2059,63 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"list_provider_features",
|
"inspect_provider",
|
||||||
{
|
{
|
||||||
title: "List provider features",
|
title: "Inspect provider",
|
||||||
description:
|
description:
|
||||||
"List provider-specific features available for a draft agent configuration, such as Codex fast_mode.",
|
"Inspect compact provider capabilities for orchestration, including modes and draft feature settings. Use list_models for the full model list.",
|
||||||
inputSchema: listProviderFeaturesInputSchema,
|
inputSchema: inspectProviderInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
provider: AgentProviderEnum,
|
provider: AgentProviderEnum,
|
||||||
|
label: z.string().nullable().optional(),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
status: z.string(),
|
||||||
|
modes: z.array(ProviderModeSchema).nullish(),
|
||||||
|
selectedModel: z.string().nullable(),
|
||||||
features: z.array(AgentFeatureSchema),
|
features: z.array(AgentFeatureSchema),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ provider, cwd, modeId, model, thinkingOptionId, featureValues }) => {
|
async ({ provider, cwd, settings }) => {
|
||||||
const features = await agentManager.listDraftFeatures({
|
const resolvedProviderModel = resolveScheduleProviderAndModel({
|
||||||
provider,
|
provider,
|
||||||
cwd: expandUserPath(cwd),
|
defaultProvider: provider,
|
||||||
...(modeId ? { modeId } : {}),
|
});
|
||||||
...(model ? { model } : {}),
|
const providerId = resolvedProviderModel.provider;
|
||||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
if (!providerRegistry) {
|
||||||
...(featureValues ? { featureValues } : {}),
|
throw new Error("Provider registry is not configured");
|
||||||
|
}
|
||||||
|
const definition = providerRegistry[providerId];
|
||||||
|
if (!definition) {
|
||||||
|
throw new Error(`Provider ${providerId} is not configured`);
|
||||||
|
}
|
||||||
|
const summary = await resolveProviderSummary(definition, childLogger);
|
||||||
|
if (!definition.enabled) {
|
||||||
|
throw new Error(`Provider '${providerId}' is disabled`);
|
||||||
|
}
|
||||||
|
if (summary.status !== "available") {
|
||||||
|
throw new Error(summary.error ?? `Provider '${providerId}' is unavailable`);
|
||||||
|
}
|
||||||
|
const resolvedCwd = resolveScopedCwd(cwd, { required: true });
|
||||||
|
const selectedModel = settings?.model ?? resolvedProviderModel.model;
|
||||||
|
const features = await agentManager.listDraftFeatures({
|
||||||
|
provider: providerId,
|
||||||
|
cwd: resolvedCwd,
|
||||||
|
...(settings?.modeId ? { modeId: settings.modeId } : {}),
|
||||||
|
...(selectedModel ? { model: selectedModel } : {}),
|
||||||
|
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
|
||||||
|
...(settings?.features ? { featureValues: settings.features } : {}),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
content: [],
|
content: [],
|
||||||
structuredContent: ensureValidJson({
|
structuredContent: ensureValidJson({
|
||||||
provider,
|
provider: providerId,
|
||||||
|
label: summary.label,
|
||||||
|
description: summary.description,
|
||||||
|
enabled: summary.enabled,
|
||||||
|
status: summary.status,
|
||||||
|
modes: summary.modes,
|
||||||
|
selectedModel: selectedModel ?? null,
|
||||||
features,
|
features,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1438,6 +1438,26 @@ describe("ACPAgentSession slash commands", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ACPAgentSession", () => {
|
describe("ACPAgentSession", () => {
|
||||||
|
test("accepts ACP extension notifications without failing the JSON-RPC connection", async () => {
|
||||||
|
const logger = createTestLogger();
|
||||||
|
const trace = vi.spyOn(logger, "trace");
|
||||||
|
const session = createSessionWithConfig({ provider: "kiro" }, logger);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
session.extNotification("_kiro.dev/session/initialized", {
|
||||||
|
sessionId: "session-1",
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(trace).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: "kiro",
|
||||||
|
method: "_kiro.dev/session/initialized",
|
||||||
|
sessionId: "session-1",
|
||||||
|
}),
|
||||||
|
"provider.acp.extension_notification",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
||||||
|
|||||||
@@ -1667,6 +1667,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||||
|
this.logger.trace(
|
||||||
|
{
|
||||||
|
agentId: this.agentId,
|
||||||
|
provider: this.provider,
|
||||||
|
sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined,
|
||||||
|
method,
|
||||||
|
rawEvent: params,
|
||||||
|
},
|
||||||
|
"provider.acp.extension_notification",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> {
|
async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> {
|
||||||
const raw = await fs.readFile(params.path, "utf8");
|
const raw = await fs.readFile(params.path, "utf8");
|
||||||
if (!params.line && !params.limit) {
|
if (!params.line && !params.limit) {
|
||||||
|
|||||||
@@ -1461,4 +1461,64 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
expect(timelineEvents).toEqual([]);
|
expect(timelineEvents).toEqual([]);
|
||||||
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("result.result is not duplicated when assistant text already streamed with zero token usage", async () => {
|
||||||
|
const queryFactory = createQueryFactoryForTurns([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
session_id: "session-third-party",
|
||||||
|
permissionMode: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
id: "assistant-third-party-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Here is the answer." }],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session_id: "session-third-party",
|
||||||
|
uuid: "assistant-third-party-event-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
result: "Here is the answer.",
|
||||||
|
is_error: false,
|
||||||
|
duration_ms: 100,
|
||||||
|
duration_api_ms: 80,
|
||||||
|
num_turns: 1,
|
||||||
|
stop_reason: null,
|
||||||
|
total_cost_usd: 0.01,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 10,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
},
|
||||||
|
permission_denials: [],
|
||||||
|
uuid: "result-third-party-1",
|
||||||
|
session_id: "session-third-party",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await session.run("turn");
|
||||||
|
await session.close();
|
||||||
|
|
||||||
|
expect(result.timeline).toEqual([{ type: "assistant_message", text: "Here is the answer." }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1568,6 +1568,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private pendingInterruptAbort = false;
|
private pendingInterruptAbort = false;
|
||||||
private lastForegroundPromptText: string | null = null;
|
private lastForegroundPromptText: string | null = null;
|
||||||
private foregroundHasVisibleActivity = false;
|
private foregroundHasVisibleActivity = false;
|
||||||
|
private activeTurnHasAssistantText = false;
|
||||||
private lastContextWindowUsedTokens: number | undefined;
|
private lastContextWindowUsedTokens: number | undefined;
|
||||||
private lastContextWindowMaxTokens: number | undefined;
|
private lastContextWindowMaxTokens: number | undefined;
|
||||||
private lastStreamRequestInputTokens: number | undefined;
|
private lastStreamRequestInputTokens: number | undefined;
|
||||||
@@ -1692,6 +1693,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const turnId = this.createTurnId("foreground");
|
const turnId = this.createTurnId("foreground");
|
||||||
this.activeForegroundTurnId = turnId;
|
this.activeForegroundTurnId = turnId;
|
||||||
this.foregroundHasVisibleActivity = false;
|
this.foregroundHasVisibleActivity = false;
|
||||||
|
this.activeTurnHasAssistantText = false;
|
||||||
this.transitionTurnState("foreground", "foreground turn started");
|
this.transitionTurnState("foreground", "foreground turn started");
|
||||||
this.clearRecentStderr();
|
this.clearRecentStderr();
|
||||||
|
|
||||||
@@ -2630,6 +2632,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
this.lastForegroundPromptText = null;
|
this.lastForegroundPromptText = null;
|
||||||
this.cancelCurrentTurn = null;
|
this.cancelCurrentTurn = null;
|
||||||
|
this.activeTurnHasAssistantText = false;
|
||||||
this.syncTurnState("foreground turn terminal");
|
this.syncTurnState("foreground turn terminal");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2645,9 +2648,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
this.lastForegroundPromptText = null;
|
this.lastForegroundPromptText = null;
|
||||||
this.cancelCurrentTurn = null;
|
this.cancelCurrentTurn = null;
|
||||||
|
this.activeTurnHasAssistantText = false;
|
||||||
this.syncTurnState("foreground turn terminal");
|
this.syncTurnState("foreground turn terminal");
|
||||||
} else if (this.autonomousTurn) {
|
} else if (this.autonomousTurn) {
|
||||||
this.autonomousTurn = null;
|
this.autonomousTurn = null;
|
||||||
|
this.activeTurnHasAssistantText = false;
|
||||||
this.syncTurnState("autonomous turn terminal");
|
this.syncTurnState("autonomous turn terminal");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2660,6 +2665,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.autonomousTurn = {
|
this.autonomousTurn = {
|
||||||
id: this.createTurnId("autonomous"),
|
id: this.createTurnId("autonomous"),
|
||||||
};
|
};
|
||||||
|
this.activeTurnHasAssistantText = false;
|
||||||
this.notifySubscribers({ type: "turn_started", provider: "claude" });
|
this.notifySubscribers({ type: "turn_started", provider: "claude" });
|
||||||
this.syncTurnState("autonomous turn started");
|
this.syncTurnState("autonomous turn started");
|
||||||
}
|
}
|
||||||
@@ -2670,6 +2676,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
|
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
|
||||||
this.autonomousTurn = null;
|
this.autonomousTurn = null;
|
||||||
|
this.activeTurnHasAssistantText = false;
|
||||||
this.syncTurnState("autonomous turn completed");
|
this.syncTurnState("autonomous turn completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2898,7 +2905,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.pendingInterruptAbort &&
|
this.pendingInterruptAbort &&
|
||||||
message.type === "result" &&
|
message.type === "result" &&
|
||||||
@@ -2909,6 +2915,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.logger.debug("Suppressing stale Claude interrupt terminal result");
|
this.logger.debug("Suppressing stale Claude interrupt terminal result");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
events.some((event) => event.type === "timeline" && event.item.type === "assistant_message")
|
||||||
|
) {
|
||||||
|
this.activeTurnHasAssistantText = true;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
this.activeForegroundTurnId &&
|
this.activeForegroundTurnId &&
|
||||||
events.some(
|
events.some(
|
||||||
@@ -3231,12 +3242,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (message.subtype === "success") {
|
if (message.subtype === "success") {
|
||||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||||
// run client-side in the Claude CLI with no model turn — output_tokens
|
// run client-side in the Claude CLI with no model turn — output_tokens
|
||||||
// is 0 and the user-visible text is carried in `result`. Surface it as
|
// is 0 and the user-visible text is carried in `result`. Surface it only
|
||||||
// an assistant message so the turn doesn't end silently. Normal turns
|
// when the turn has not already emitted assistant text so zero-token
|
||||||
// have output_tokens > 0 and their text is already in the stream.
|
// accounting from provider gateways does not duplicate streamed output.
|
||||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||||
const outputTokens = message.usage?.output_tokens;
|
const outputTokens = message.usage?.output_tokens;
|
||||||
if (resultText.length > 0 && outputTokens === 0) {
|
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
|
||||||
events.push({
|
events.push({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
|
|||||||
@@ -1352,6 +1352,40 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not synthesize a parent sub-agent failure from child error state alone", () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
threadId: "test-thread",
|
||||||
|
item: {
|
||||||
|
type: "collabAgentToolCall",
|
||||||
|
id: "call-sub-agent-transient-child-error",
|
||||||
|
tool: "spawnAgent",
|
||||||
|
status: "completed",
|
||||||
|
prompt: "Validate the child agent result.",
|
||||||
|
receiverThreadIds: ["child-thread-1"],
|
||||||
|
agentsStates: {
|
||||||
|
"child-thread-1": { status: "error", message: "Sub-agent failed" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events.at(-1)?.item).toMatchObject({
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "call-sub-agent-transient-child-error",
|
||||||
|
name: "Sub-agent",
|
||||||
|
status: "running",
|
||||||
|
error: null,
|
||||||
|
detail: {
|
||||||
|
type: "sub_agent",
|
||||||
|
subAgentType: "Sub-agent",
|
||||||
|
description: "Validate the child agent result.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("loads Codex persisted history from the app-server thread", async () => {
|
test("loads Codex persisted history from the app-server thread", async () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const requests: Array<{ method: string; params: unknown }> = [];
|
const requests: Array<{ method: string; params: unknown }> = [];
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
resolveProviderCommandPrefix,
|
resolveProviderCommandPrefix,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable, probeExecutable } from "../../../utils/executable.js";
|
||||||
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
||||||
import { spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||||
@@ -375,8 +375,61 @@ function mergeCodexConfiguredDefaults(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function codexMicrosoftStorePackageRoot(): string | null {
|
||||||
|
const localAppData = process.env.LOCALAPPDATA;
|
||||||
|
if (!localAppData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.join(localAppData, "Packages");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageRoot = codexMicrosoftStorePackageRoot();
|
||||||
|
if (!packageRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: Dirent[];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(packageRoot, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codexPackages = entries
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_"))
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const packageName of codexPackages) {
|
||||||
|
const candidate = path.join(
|
||||||
|
packageRoot,
|
||||||
|
packageName,
|
||||||
|
"LocalCache",
|
||||||
|
"Local",
|
||||||
|
"OpenAI",
|
||||||
|
"Codex",
|
||||||
|
"bin",
|
||||||
|
"codex.exe",
|
||||||
|
);
|
||||||
|
if (await probeExecutable(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDefaultCodexBinary(): Promise<string | null> {
|
||||||
|
return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary());
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveCodexBinary(): Promise<string> {
|
async function resolveCodexBinary(): Promise<string> {
|
||||||
const found = await findExecutable("codex");
|
const found = await findDefaultCodexBinary();
|
||||||
if (found) {
|
if (found) {
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
@@ -5299,13 +5352,13 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
if (command?.mode === "replace") {
|
if (command?.mode === "replace") {
|
||||||
return await isCommandAvailable(command.argv[0]);
|
return await isCommandAvailable(command.argv[0]);
|
||||||
}
|
}
|
||||||
return await isCommandAvailable("codex");
|
return (await findDefaultCodexBinary()) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||||
try {
|
try {
|
||||||
const available = await this.isAvailable();
|
const available = await this.isAvailable();
|
||||||
const resolvedBinary = await findExecutable("codex");
|
const resolvedBinary = await findDefaultCodexBinary();
|
||||||
const entries: Array<{ label: string; value: string }> = [
|
const entries: Array<{ label: string; value: string }> = [
|
||||||
{
|
{
|
||||||
label: "Binary",
|
label: "Binary",
|
||||||
@@ -5448,6 +5501,8 @@ export const __codexAppServerInternals = {
|
|||||||
CodexAppServerClient,
|
CodexAppServerClient,
|
||||||
codexModelSupportsFastMode,
|
codexModelSupportsFastMode,
|
||||||
CodexAppServerAgentSession,
|
CodexAppServerAgentSession,
|
||||||
|
findCodexMicrosoftStoreBinary,
|
||||||
|
findDefaultCodexBinary,
|
||||||
formatCodexQuestionPrompts,
|
formatCodexQuestionPrompts,
|
||||||
mapCodexQuestionRequestToToolCall,
|
mapCodexQuestionRequestToToolCall,
|
||||||
mapCodexPatchNotificationToToolCall,
|
mapCodexPatchNotificationToToolCall,
|
||||||
|
|||||||
@@ -212,6 +212,64 @@ describe("codex tool-call mapper", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not fail a collabAgentToolCall from child error state alone", () => {
|
||||||
|
const item = mapCodexToolCallFromThreadItem({
|
||||||
|
type: "collabAgentToolCall",
|
||||||
|
id: "call-sub-agent-transient-child-error",
|
||||||
|
tool: "spawnAgent",
|
||||||
|
status: "completed",
|
||||||
|
prompt: "Inspect the Codex stream path.",
|
||||||
|
receiverThreadIds: ["child-thread-1"],
|
||||||
|
agentsStates: {
|
||||||
|
"child-thread-1": { status: "error", message: "Sub-agent failed" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item).toEqual({
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "call-sub-agent-transient-child-error",
|
||||||
|
name: "Sub-agent",
|
||||||
|
status: "running",
|
||||||
|
error: null,
|
||||||
|
detail: {
|
||||||
|
type: "sub_agent",
|
||||||
|
subAgentType: "Sub-agent",
|
||||||
|
description: "Inspect the Codex stream path.",
|
||||||
|
log: "",
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still fails a collabAgentToolCall from an explicitly failed child state", () => {
|
||||||
|
const item = mapCodexToolCallFromThreadItem({
|
||||||
|
type: "collabAgentToolCall",
|
||||||
|
id: "call-sub-agent-child-failed",
|
||||||
|
tool: "spawnAgent",
|
||||||
|
status: "completed",
|
||||||
|
prompt: "Inspect the Codex stream path.",
|
||||||
|
receiverThreadIds: ["child-thread-1"],
|
||||||
|
agentsStates: {
|
||||||
|
"child-thread-1": { status: "failed", message: "Child failed" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item).toEqual({
|
||||||
|
type: "tool_call",
|
||||||
|
callId: "call-sub-agent-child-failed",
|
||||||
|
name: "Sub-agent",
|
||||||
|
status: "failed",
|
||||||
|
error: { message: "Sub-agent failed" },
|
||||||
|
detail: {
|
||||||
|
type: "sub_agent",
|
||||||
|
subAgentType: "Sub-agent",
|
||||||
|
description: "Inspect the Codex stream path.",
|
||||||
|
log: "",
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("maps mcp read_file completion with detail", () => {
|
it("maps mcp read_file completion with detail", () => {
|
||||||
const item = mapCodexToolCallFromThreadItem(
|
const item = mapCodexToolCallFromThreadItem(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -544,6 +544,14 @@ function readStatus(value: unknown): string | undefined {
|
|||||||
return typeof value.status === "string" ? value.status : undefined;
|
return typeof value.status === "string" ? value.status : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
||||||
|
const normalized = status.trim().toLowerCase();
|
||||||
|
if (normalized === "error" || normalized === "errored") {
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
return normalizeToolCallStatus(status, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveCollabAgentStatus(
|
function resolveCollabAgentStatus(
|
||||||
item: z.infer<typeof CodexCollabAgentToolCallItemSchema>,
|
item: z.infer<typeof CodexCollabAgentToolCallItemSchema>,
|
||||||
): ToolCallTimelineItem["status"] {
|
): ToolCallTimelineItem["status"] {
|
||||||
@@ -551,10 +559,15 @@ function resolveCollabAgentStatus(
|
|||||||
return "failed";
|
return "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parentStatus = normalizeToolCallStatus(item.status, null, null);
|
||||||
|
if (parentStatus === "failed") {
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
const childStatuses = Object.values(item.agentsStates ?? {})
|
const childStatuses = Object.values(item.agentsStates ?? {})
|
||||||
.map(readStatus)
|
.map(readStatus)
|
||||||
.filter((status): status is string => typeof status === "string" && status.trim().length > 0)
|
.filter((status): status is string => typeof status === "string" && status.trim().length > 0)
|
||||||
.map((status) => normalizeToolCallStatus(status, null, null));
|
.map(normalizeCollabAgentChildStatus);
|
||||||
|
|
||||||
if (childStatuses.some((status) => status === "failed")) {
|
if (childStatuses.some((status) => status === "failed")) {
|
||||||
return "failed";
|
return "failed";
|
||||||
@@ -566,7 +579,7 @@ function resolveCollabAgentStatus(
|
|||||||
return childStatuses.every((status) => status === "completed") ? "completed" : "running";
|
return childStatuses.every((status) => status === "completed") ? "completed" : "running";
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeToolCallStatus(item.status, item.error ?? null, null);
|
return parentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMcpToolName(server: string | undefined, tool: string): string {
|
function buildMcpToolName(server: string | undefined, tool: string): string {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ import {
|
|||||||
type ResolvedCommand,
|
type ResolvedCommand,
|
||||||
type Skill,
|
type Skill,
|
||||||
type WriteToolInput,
|
type WriteToolInput,
|
||||||
} from "@mariozechner/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
|
import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface PiSessionRecoveryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal
|
// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal
|
||||||
// 2026-11-13, once upstream @mariozechner/pi-ai recognizes this overflow.
|
// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow.
|
||||||
const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i;
|
const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i;
|
||||||
|
|
||||||
const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [
|
const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
@@ -9,10 +9,11 @@ import { AgentManager } from "../agent-manager.js";
|
|||||||
import { AgentStorage } from "../agent-storage.js";
|
import { AgentStorage } from "../agent-storage.js";
|
||||||
|
|
||||||
import { ClaudeAgentClient } from "./claude/agent.js";
|
import { ClaudeAgentClient } from "./claude/agent.js";
|
||||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
import { __codexAppServerInternals, CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||||
|
|
||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
|
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
||||||
PATH: process.env.PATH,
|
PATH: process.env.PATH,
|
||||||
PATHEXT: process.env.PATHEXT,
|
PATHEXT: process.env.PATHEXT,
|
||||||
};
|
};
|
||||||
@@ -31,7 +32,19 @@ function isolatePathTo(dir: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isolateCodexDefaultDiscoveryTo(dir: string): void {
|
||||||
|
isolatePathTo(dir);
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
process.env.LOCALAPPDATA = dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
if (originalEnv.LOCALAPPDATA === undefined) {
|
||||||
|
delete process.env.LOCALAPPDATA;
|
||||||
|
} else {
|
||||||
|
process.env.LOCALAPPDATA = originalEnv.LOCALAPPDATA;
|
||||||
|
}
|
||||||
process.env.PATH = originalEnv.PATH;
|
process.env.PATH = originalEnv.PATH;
|
||||||
process.env.PATHEXT = originalEnv.PATHEXT;
|
process.env.PATHEXT = originalEnv.PATHEXT;
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
@@ -42,12 +55,51 @@ afterEach(() => {
|
|||||||
describe("default provider availability", () => {
|
describe("default provider availability", () => {
|
||||||
test("Codex reports unavailable when the default command cannot be resolved", async () => {
|
test("Codex reports unavailable when the default command cannot be resolved", async () => {
|
||||||
const binDir = makeTempDir("provider-availability-codex-");
|
const binDir = makeTempDir("provider-availability-codex-");
|
||||||
isolatePathTo(binDir);
|
isolateCodexDefaultDiscoveryTo(binDir);
|
||||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||||
|
|
||||||
await expect(client.isAvailable()).resolves.toBe(false);
|
await expect(client.isAvailable()).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Codex reports available from a Microsoft Store install path when PATH misses codex", async () => {
|
||||||
|
const originalPlatform = process.platform;
|
||||||
|
const originalLocalAppData = process.env.LOCALAPPDATA;
|
||||||
|
const root = makeTempDir("provider-availability-codex-store-");
|
||||||
|
const emptyPathDir = join(root, "empty-path");
|
||||||
|
const codexBinDir = join(
|
||||||
|
root,
|
||||||
|
"Packages",
|
||||||
|
"OpenAI.Codex_abc123",
|
||||||
|
"LocalCache",
|
||||||
|
"Local",
|
||||||
|
"OpenAI",
|
||||||
|
"Codex",
|
||||||
|
"bin",
|
||||||
|
);
|
||||||
|
const codexExe = join(codexBinDir, "codex.exe");
|
||||||
|
mkdirSync(emptyPathDir, { recursive: true });
|
||||||
|
mkdirSync(codexBinDir, { recursive: true });
|
||||||
|
copyFileSync(process.execPath, codexExe);
|
||||||
|
Object.defineProperty(process, "platform", { value: "win32", writable: true });
|
||||||
|
process.env.LOCALAPPDATA = root;
|
||||||
|
isolatePathTo(emptyPathDir);
|
||||||
|
process.env.PATHEXT = ".EXE";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||||
|
|
||||||
|
await expect(__codexAppServerInternals.findDefaultCodexBinary()).resolves.toBe(codexExe);
|
||||||
|
await expect(client.isAvailable()).resolves.toBe(true);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
||||||
|
if (originalLocalAppData === undefined) {
|
||||||
|
delete process.env.LOCALAPPDATA;
|
||||||
|
} else {
|
||||||
|
process.env.LOCALAPPDATA = originalLocalAppData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("Claude reports unavailable when the default command cannot be resolved", async () => {
|
test("Claude reports unavailable when the default command cannot be resolved", async () => {
|
||||||
const binDir = makeTempDir("provider-availability-claude-");
|
const binDir = makeTempDir("provider-availability-claude-");
|
||||||
isolatePathTo(binDir);
|
isolatePathTo(binDir);
|
||||||
@@ -66,7 +118,7 @@ describe("default provider availability", () => {
|
|||||||
|
|
||||||
test("AgentManager reports Codex unavailable without throwing", async () => {
|
test("AgentManager reports Codex unavailable without throwing", async () => {
|
||||||
const binDir = makeTempDir("provider-availability-manager-bin-");
|
const binDir = makeTempDir("provider-availability-manager-bin-");
|
||||||
isolatePathTo(binDir);
|
isolateCodexDefaultDiscoveryTo(binDir);
|
||||||
const workdir = makeTempDir("provider-availability-manager-work-");
|
const workdir = makeTempDir("provider-availability-manager-work-");
|
||||||
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
@@ -88,7 +140,7 @@ describe("default provider availability", () => {
|
|||||||
|
|
||||||
test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => {
|
test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => {
|
||||||
const binDir = makeTempDir("provider-availability-resume-bin-");
|
const binDir = makeTempDir("provider-availability-resume-bin-");
|
||||||
isolatePathTo(binDir);
|
isolateCodexDefaultDiscoveryTo(binDir);
|
||||||
const workdir = makeTempDir("provider-availability-resume-work-");
|
const workdir = makeTempDir("provider-availability-resume-work-");
|
||||||
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
|
|||||||
@@ -936,6 +936,16 @@ export async function createPaseoDaemon(
|
|||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
github,
|
github,
|
||||||
config.pushNotificationSender,
|
config.pushNotificationSender,
|
||||||
|
{
|
||||||
|
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
||||||
|
relay: {
|
||||||
|
enabled: relayEnabled,
|
||||||
|
endpoint: relayEndpoint,
|
||||||
|
publicEndpoint: relayPublicEndpoint,
|
||||||
|
useTls: relayUseTls,
|
||||||
|
publicUseTls: relayPublicUseTls,
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (relayEnabled) {
|
if (relayEnabled) {
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ const checkoutGitMocks = vi.hoisted(() => ({
|
|||||||
mergeToBase: vi.fn(),
|
mergeToBase: vi.fn(),
|
||||||
pullCurrentBranch: vi.fn(),
|
pullCurrentBranch: vi.fn(),
|
||||||
pushCurrentBranch: vi.fn(),
|
pushCurrentBranch: vi.fn(),
|
||||||
|
renameCurrentBranch: vi.fn(),
|
||||||
resolveBranchCheckout: vi.fn(),
|
resolveBranchCheckout: vi.fn(),
|
||||||
warmCheckoutShortstatInBackground: vi.fn(),
|
warmCheckoutShortstatInBackground: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -203,6 +204,7 @@ vi.mock("../utils/checkout-git.js", async (importOriginal) => {
|
|||||||
mergeToBase: checkoutGitMocks.mergeToBase,
|
mergeToBase: checkoutGitMocks.mergeToBase,
|
||||||
pullCurrentBranch: checkoutGitMocks.pullCurrentBranch,
|
pullCurrentBranch: checkoutGitMocks.pullCurrentBranch,
|
||||||
pushCurrentBranch: checkoutGitMocks.pushCurrentBranch,
|
pushCurrentBranch: checkoutGitMocks.pushCurrentBranch,
|
||||||
|
renameCurrentBranch: checkoutGitMocks.renameCurrentBranch,
|
||||||
resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout,
|
resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout,
|
||||||
warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground,
|
warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground,
|
||||||
};
|
};
|
||||||
@@ -927,6 +929,16 @@ function createWorkspaceGitSnapshot(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTerminalManagerStub(options?: { setTerminalTitle?: ReturnType<typeof vi.fn> }): {
|
||||||
|
setTerminalTitle: ReturnType<typeof vi.fn>;
|
||||||
|
subscribeTerminalsChanged: ReturnType<typeof vi.fn>;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
setTerminalTitle: options?.setTerminalTitle ?? vi.fn(),
|
||||||
|
subscribeTerminalsChanged: vi.fn(() => () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -3158,6 +3170,208 @@ describe("session checkout switch branch handling", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("session checkout rename branch handling", () => {
|
||||||
|
test("rejects invalid branch slugs without renaming", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn(),
|
||||||
|
peekSnapshot: vi.fn(),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "checkout.rename_branch.request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
branch: "Feature Name",
|
||||||
|
requestId: "request-rename-invalid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkoutGitMocks.renameCurrentBranch).not.toHaveBeenCalled();
|
||||||
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout.rename_branch.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
success: false,
|
||||||
|
currentBranch: null,
|
||||||
|
error: {
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message:
|
||||||
|
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
||||||
|
},
|
||||||
|
requestId: "request-rename-invalid",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports null current branch when branch rename fails", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn(),
|
||||||
|
peekSnapshot: vi.fn(),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ workspaceGitService, messages });
|
||||||
|
checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists"));
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "checkout.rename_branch.request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
branch: "feature/new-name",
|
||||||
|
requestId: "request-rename-failure",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith(
|
||||||
|
"/tmp/repo",
|
||||||
|
"feature/new-name",
|
||||||
|
);
|
||||||
|
expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout.rename_branch.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
success: false,
|
||||||
|
currentBranch: null,
|
||||||
|
error: {
|
||||||
|
code: "UNKNOWN",
|
||||||
|
message: "branch already exists",
|
||||||
|
},
|
||||||
|
requestId: "request-rename-failure",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forces workspace git refresh after renaming the current branch", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const github = { invalidate: vi.fn() };
|
||||||
|
const workspaceGitService = {
|
||||||
|
getSnapshot: vi.fn().mockResolvedValue(
|
||||||
|
createWorkspaceGitSnapshot("/tmp/repo", {
|
||||||
|
git: {
|
||||||
|
currentBranch: "feature/new-name",
|
||||||
|
isDirty: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
peekSnapshot: vi.fn(() =>
|
||||||
|
createWorkspaceGitSnapshot("/tmp/repo", {
|
||||||
|
git: { currentBranch: "feature/old-name" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const session = createSessionForTest({ github, workspaceGitService, messages });
|
||||||
|
checkoutGitMocks.renameCurrentBranch.mockResolvedValue({
|
||||||
|
previousBranch: "feature/old-name",
|
||||||
|
currentBranch: "feature/new-name",
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "checkout.rename_branch.request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
branch: "feature/new-name",
|
||||||
|
requestId: "request-rename-success",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith(
|
||||||
|
"/tmp/repo",
|
||||||
|
"feature/new-name",
|
||||||
|
);
|
||||||
|
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
|
||||||
|
force: true,
|
||||||
|
reason: "rename-branch",
|
||||||
|
});
|
||||||
|
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" });
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "checkout.rename_branch.response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
success: true,
|
||||||
|
currentBranch: "feature/new-name",
|
||||||
|
error: null,
|
||||||
|
requestId: "request-rename-success",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session terminal rename handling", () => {
|
||||||
|
test("rejects an empty terminal title without calling the terminal manager", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const terminalManager = createTerminalManagerStub();
|
||||||
|
const session = createSessionForTest({ terminalManager, messages });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "terminal.rename.request",
|
||||||
|
terminalId: "terminal-1",
|
||||||
|
title: " ",
|
||||||
|
requestId: "request-empty-title",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(terminalManager.setTerminalTitle).not.toHaveBeenCalled();
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "terminal.rename.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "request-empty-title",
|
||||||
|
success: false,
|
||||||
|
error: "Title is required",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports when the terminal manager cannot find the terminal", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const terminalManager = createTerminalManagerStub({
|
||||||
|
setTerminalTitle: vi.fn(() => false),
|
||||||
|
});
|
||||||
|
const session = createSessionForTest({ terminalManager, messages });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "terminal.rename.request",
|
||||||
|
terminalId: "missing-terminal",
|
||||||
|
title: "Renamed terminal",
|
||||||
|
requestId: "request-missing-terminal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith(
|
||||||
|
"missing-terminal",
|
||||||
|
"Renamed terminal",
|
||||||
|
);
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "terminal.rename.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "request-missing-terminal",
|
||||||
|
success: false,
|
||||||
|
error: "Terminal not found",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trims and sets a valid terminal title", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const terminalManager = createTerminalManagerStub({
|
||||||
|
setTerminalTitle: vi.fn(() => true),
|
||||||
|
});
|
||||||
|
const session = createSessionForTest({ terminalManager, messages });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "terminal.rename.request",
|
||||||
|
terminalId: "terminal-1",
|
||||||
|
title: " Renamed terminal ",
|
||||||
|
requestId: "request-title-success",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith("terminal-1", "Renamed terminal");
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "terminal.rename.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "request-title-success",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("session branch suggestions handling", () => {
|
describe("session branch suggestions handling", () => {
|
||||||
test("lists branch suggestions through the workspace git service", async () => {
|
test("lists branch suggestions through the workspace git service", async () => {
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type FileExplorerRequest,
|
type FileExplorerRequest,
|
||||||
type FileDownloadTokenRequest,
|
type FileDownloadTokenRequest,
|
||||||
type GitSetupOptions,
|
type GitSetupOptions,
|
||||||
|
type CheckoutRenameBranchRequest,
|
||||||
type StartWorkspaceScriptRequest,
|
type StartWorkspaceScriptRequest,
|
||||||
type CloseItemsRequest,
|
type CloseItemsRequest,
|
||||||
type SubscribeCheckoutDiffRequest,
|
type SubscribeCheckoutDiffRequest,
|
||||||
@@ -47,6 +48,8 @@ import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"
|
|||||||
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
|
||||||
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
|
||||||
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
|
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
|
||||||
|
import { getPidLockInfo } from "./pid-lock.js";
|
||||||
|
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||||
import {
|
import {
|
||||||
DictationStreamManager,
|
DictationStreamManager,
|
||||||
type DictationStreamOutboundMessage,
|
type DictationStreamOutboundMessage,
|
||||||
@@ -192,7 +195,9 @@ import {
|
|||||||
pullCurrentBranch,
|
pullCurrentBranch,
|
||||||
pushCurrentBranch,
|
pushCurrentBranch,
|
||||||
createPullRequest,
|
createPullRequest,
|
||||||
|
renameCurrentBranch,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
|
import { validateBranchSlug } from "../utils/branch-slug.js";
|
||||||
import { getProjectIcon } from "../utils/project-icon.js";
|
import { getProjectIcon } from "../utils/project-icon.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
||||||
@@ -306,8 +311,8 @@ type GitMutationRefreshReason =
|
|||||||
| "disable-pr-auto-merge"
|
| "disable-pr-auto-merge"
|
||||||
| "create-pr"
|
| "create-pr"
|
||||||
| "switch-branch"
|
| "switch-branch"
|
||||||
| "create-branch"
|
|
||||||
| "rename-branch"
|
| "rename-branch"
|
||||||
|
| "create-branch"
|
||||||
| "stash-push"
|
| "stash-push"
|
||||||
| "stash-pop"
|
| "stash-pop"
|
||||||
| "create-worktree";
|
| "create-worktree";
|
||||||
@@ -429,6 +434,7 @@ type ProcessingPhase = "idle" | "transcribing";
|
|||||||
|
|
||||||
interface WorkspaceGitWatchTarget {
|
interface WorkspaceGitWatchTarget {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
workspaceId: string;
|
||||||
watchers: FSWatcher[];
|
watchers: FSWatcher[];
|
||||||
debounceTimer: ReturnType<typeof setTimeout> | null;
|
debounceTimer: ReturnType<typeof setTimeout> | null;
|
||||||
refreshPromise: Promise<void> | null;
|
refreshPromise: Promise<void> | null;
|
||||||
@@ -595,6 +601,18 @@ export interface SessionOptions {
|
|||||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
|
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||||
providerOverrides?: Record<string, ProviderOverride>;
|
providerOverrides?: Record<string, ProviderOverride>;
|
||||||
isDev?: boolean;
|
isDev?: boolean;
|
||||||
|
serverId?: string;
|
||||||
|
daemonVersion?: string;
|
||||||
|
daemonRuntimeConfig?: {
|
||||||
|
listen: string | null;
|
||||||
|
relay: {
|
||||||
|
enabled: boolean;
|
||||||
|
endpoint: string;
|
||||||
|
publicEndpoint: string;
|
||||||
|
useTls: boolean;
|
||||||
|
publicUseTls: boolean;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionLifecycleIntent =
|
export type SessionLifecycleIntent =
|
||||||
@@ -805,6 +823,9 @@ export class Session {
|
|||||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||||
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
|
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
|
||||||
private readonly isDev: boolean;
|
private readonly isDev: boolean;
|
||||||
|
private readonly serverId: string | undefined;
|
||||||
|
private readonly daemonVersion: string | undefined;
|
||||||
|
private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"];
|
||||||
private voiceModeAgentId: string | null = null;
|
private voiceModeAgentId: string | null = null;
|
||||||
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
|
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
|
||||||
|
|
||||||
@@ -850,6 +871,9 @@ export class Session {
|
|||||||
agentProviderRuntimeSettings,
|
agentProviderRuntimeSettings,
|
||||||
providerOverrides,
|
providerOverrides,
|
||||||
isDev,
|
isDev,
|
||||||
|
serverId,
|
||||||
|
daemonVersion,
|
||||||
|
daemonRuntimeConfig,
|
||||||
} = options;
|
} = options;
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.appVersion = appVersion ?? null;
|
this.appVersion = appVersion ?? null;
|
||||||
@@ -901,6 +925,9 @@ export class Session {
|
|||||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||||
this.providerOverrides = providerOverrides;
|
this.providerOverrides = providerOverrides;
|
||||||
this.isDev = isDev === true;
|
this.isDev = isDev === true;
|
||||||
|
this.serverId = serverId;
|
||||||
|
this.daemonVersion = daemonVersion;
|
||||||
|
this.daemonRuntimeConfig = daemonRuntimeConfig;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
this.workspaceDirectory = new WorkspaceDirectory({
|
this.workspaceDirectory = new WorkspaceDirectory({
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
@@ -1864,6 +1891,10 @@ export class Session {
|
|||||||
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
|
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
|
||||||
});
|
});
|
||||||
return undefined;
|
return undefined;
|
||||||
|
case "daemon.get_status.request":
|
||||||
|
return this.handleDaemonGetStatusRequest(msg);
|
||||||
|
case "daemon.get_pairing_offer.request":
|
||||||
|
return this.handleDaemonGetPairingOfferRequest(msg);
|
||||||
case "set_daemon_config_request":
|
case "set_daemon_config_request":
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "set_daemon_config_response",
|
type: "set_daemon_config_response",
|
||||||
@@ -2019,12 +2050,8 @@ export class Session {
|
|||||||
return undefined;
|
return undefined;
|
||||||
case "checkout_switch_branch_request":
|
case "checkout_switch_branch_request":
|
||||||
return this.handleCheckoutSwitchBranchRequest(msg);
|
return this.handleCheckoutSwitchBranchRequest(msg);
|
||||||
case "stash_save_request":
|
case "checkout.rename_branch.request":
|
||||||
return this.handleStashSaveRequest(msg);
|
return this.handleCheckoutRenameBranchRequest(msg);
|
||||||
case "stash_pop_request":
|
|
||||||
return this.handleStashPopRequest(msg);
|
|
||||||
case "stash_list_request":
|
|
||||||
return this.handleStashListRequest(msg);
|
|
||||||
case "checkout_commit_request":
|
case "checkout_commit_request":
|
||||||
return this.handleCheckoutCommitRequest(msg);
|
return this.handleCheckoutCommitRequest(msg);
|
||||||
case "checkout_merge_request":
|
case "checkout_merge_request":
|
||||||
@@ -2047,6 +2074,12 @@ export class Session {
|
|||||||
return this.handlePullRequestTimelineRequest(msg);
|
return this.handlePullRequestTimelineRequest(msg);
|
||||||
case "github_search_request":
|
case "github_search_request":
|
||||||
return this.handleGitHubSearchRequest(msg);
|
return this.handleGitHubSearchRequest(msg);
|
||||||
|
case "stash_save_request":
|
||||||
|
return this.handleStashSaveRequest(msg);
|
||||||
|
case "stash_pop_request":
|
||||||
|
return this.handleStashPopRequest(msg);
|
||||||
|
case "stash_list_request":
|
||||||
|
return this.handleStashListRequest(msg);
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -3813,6 +3846,86 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleDaemonGetStatusRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "daemon.get_status.request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const pidInfo = await getPidLockInfo(this.paseoHome);
|
||||||
|
const providers = (await this.agentManager.listProviderAvailability()).map((p) => ({
|
||||||
|
provider: p.provider,
|
||||||
|
available: p.available,
|
||||||
|
error: p.error ?? null,
|
||||||
|
}));
|
||||||
|
this.emit({
|
||||||
|
type: "daemon.get_status.response",
|
||||||
|
payload: {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
serverId: this.serverId ?? "",
|
||||||
|
version: this.daemonVersion ?? null,
|
||||||
|
pid: process.pid,
|
||||||
|
nodePath: process.execPath,
|
||||||
|
startedAt: pidInfo?.startedAt ?? null,
|
||||||
|
listen: this.daemonRuntimeConfig?.listen ?? null,
|
||||||
|
relay: this.daemonRuntimeConfig?.relay ?? null,
|
||||||
|
providers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.error({ err: error }, "Failed to handle daemon status request");
|
||||||
|
this.emit({
|
||||||
|
type: "daemon.get_status.response",
|
||||||
|
payload: {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
serverId: this.serverId ?? "",
|
||||||
|
version: this.daemonVersion ?? null,
|
||||||
|
pid: process.pid,
|
||||||
|
nodePath: process.execPath,
|
||||||
|
startedAt: null,
|
||||||
|
listen: null,
|
||||||
|
relay: null,
|
||||||
|
providers: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDaemonGetPairingOfferRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "daemon.get_pairing_offer.request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const relay = this.daemonRuntimeConfig?.relay;
|
||||||
|
const pairing = await generateLocalPairingOffer({
|
||||||
|
paseoHome: this.paseoHome,
|
||||||
|
relayEnabled: relay?.enabled ?? true,
|
||||||
|
relayEndpoint: relay?.endpoint,
|
||||||
|
relayPublicEndpoint: relay?.publicEndpoint,
|
||||||
|
relayUseTls: relay?.useTls,
|
||||||
|
relayPublicUseTls: relay?.publicUseTls,
|
||||||
|
includeQr: true,
|
||||||
|
logger: this.sessionLogger,
|
||||||
|
});
|
||||||
|
this.emit({
|
||||||
|
type: "daemon.get_pairing_offer.response",
|
||||||
|
payload: {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
url: pairing.url ?? "",
|
||||||
|
qr: pairing.qr ?? null,
|
||||||
|
relayEnabled: pairing.relayEnabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.sessionLogger.error({ err: error }, "Failed to handle daemon pairing offer request");
|
||||||
|
this.emit({
|
||||||
|
type: "rpc_error",
|
||||||
|
payload: {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
requestType: "daemon.get_pairing_offer.request",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleListAvailableProvidersRequest(
|
private async handleListAvailableProvidersRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -4826,16 +4939,35 @@ export class Session {
|
|||||||
target.lastBranchName = workspace?.name ?? null;
|
target.lastBranchName = workspace?.name ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void {
|
||||||
|
const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd));
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBranchName = target.lastBranchName;
|
||||||
|
if (branchName === previousBranchName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.lastBranchName = branchName;
|
||||||
|
this.onBranchChanged?.(target.workspaceId, previousBranchName, branchName);
|
||||||
|
}
|
||||||
|
|
||||||
private syncWorkspaceGitObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
private syncWorkspaceGitObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
||||||
for (const workspace of workspaces) {
|
for (const workspace of workspaces) {
|
||||||
this.syncWorkspaceGitObserver(workspace.workspaceDirectory, {
|
this.syncWorkspaceGitObserver(workspace.workspaceDirectory, {
|
||||||
isGit: workspace.projectKind === "git",
|
isGit: workspace.projectKind === "git",
|
||||||
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace);
|
this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncWorkspaceGitObserver(cwd: string, options: { isGit: boolean }): void {
|
private syncWorkspaceGitObserver(
|
||||||
|
cwd: string,
|
||||||
|
options: { isGit: boolean; workspaceId: string },
|
||||||
|
): void {
|
||||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||||
if (!options.isGit) {
|
if (!options.isGit) {
|
||||||
this.removeWorkspaceGitSubscription(normalizedCwd);
|
this.removeWorkspaceGitSubscription(normalizedCwd);
|
||||||
@@ -4846,9 +4978,22 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const target: WorkspaceGitWatchTarget = {
|
||||||
|
cwd: normalizedCwd,
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
watchers: [],
|
||||||
|
debounceTimer: null,
|
||||||
|
refreshPromise: null,
|
||||||
|
refreshQueued: false,
|
||||||
|
latestDescriptorStateKey: null,
|
||||||
|
lastBranchName: null,
|
||||||
|
};
|
||||||
|
this.workspaceGitWatchTargets.set(normalizedCwd, target);
|
||||||
|
|
||||||
const subscription = this.workspaceGitService.registerWorkspace(
|
const subscription = this.workspaceGitService.registerWorkspace(
|
||||||
{ cwd: normalizedCwd },
|
{ cwd: normalizedCwd },
|
||||||
(snapshot) => {
|
(snapshot) => {
|
||||||
|
this.handleWorkspaceGitBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
|
||||||
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
|
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
|
||||||
this.emitCheckoutStatusUpdate(normalizedCwd, snapshot);
|
this.emitCheckoutStatusUpdate(normalizedCwd, snapshot);
|
||||||
},
|
},
|
||||||
@@ -4955,6 +5100,58 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise<void> {
|
||||||
|
const { cwd, branch, requestId } = msg;
|
||||||
|
const validation = validateBranchSlug(branch);
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
this.emit({
|
||||||
|
type: "checkout.rename_branch.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
success: false,
|
||||||
|
currentBranch: null,
|
||||||
|
error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")),
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await renameCurrentBranch(cwd, branch);
|
||||||
|
await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
|
||||||
|
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||||
|
this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
|
||||||
|
|
||||||
|
// Push a workspace_update immediately so the sidebar/header reflect
|
||||||
|
// the new branch name without waiting for the background git watcher.
|
||||||
|
await this.emitWorkspaceUpdateForCwd(cwd);
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "checkout.rename_branch.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
success: true,
|
||||||
|
currentBranch: result.currentBranch,
|
||||||
|
error: null,
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.emit({
|
||||||
|
type: "checkout.rename_branch.response",
|
||||||
|
payload: {
|
||||||
|
cwd,
|
||||||
|
success: false,
|
||||||
|
currentBranch: null,
|
||||||
|
error: toCheckoutError(error),
|
||||||
|
requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Stash handlers
|
// Stash handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -6672,7 +6869,10 @@ export class Session {
|
|||||||
|
|
||||||
private async emitWorkspaceUpdateForCwd(
|
private async emitWorkspaceUpdateForCwd(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
options?: { skipReconcile?: boolean; dedupeGitState?: boolean },
|
options?: {
|
||||||
|
skipReconcile?: boolean;
|
||||||
|
dedupeGitState?: boolean;
|
||||||
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const workspaces = await this.workspaceRegistry.list();
|
const workspaces = await this.workspaceRegistry.list();
|
||||||
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
|
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
|
||||||
|
import { ScriptRouteStore } from "./script-proxy.js";
|
||||||
import { Session, type SessionOptions } from "./session.js";
|
import { Session, type SessionOptions } from "./session.js";
|
||||||
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
import { asInternals, createStub } from "./test-utils/class-mocks.js";
|
||||||
|
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
import type {
|
import type {
|
||||||
WorkspaceGitListener,
|
WorkspaceGitListener,
|
||||||
WorkspaceGitRuntimeSnapshot,
|
WorkspaceGitRuntimeSnapshot,
|
||||||
@@ -24,7 +27,7 @@ interface SessionInternals {
|
|||||||
lastEmittedByWorkspaceId: Map<string, unknown>;
|
lastEmittedByWorkspaceId: Map<string, unknown>;
|
||||||
};
|
};
|
||||||
buildWorkspaceDescriptorMap: () => Promise<Map<string, unknown>>;
|
buildWorkspaceDescriptorMap: () => Promise<Map<string, unknown>>;
|
||||||
syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean }): void;
|
syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean; workspaceId: string }): void;
|
||||||
listAgentPayloads: () => Promise<unknown[]>;
|
listAgentPayloads: () => Promise<unknown[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +95,15 @@ function createWorkspaceRuntimeSnapshot(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionForWorkspaceGitWatchTests(): {
|
function createSessionForWorkspaceGitWatchTests(options?: {
|
||||||
|
onBranchChanged?: (
|
||||||
|
workspaceId: string,
|
||||||
|
oldBranch: string | null,
|
||||||
|
newBranch: string | null,
|
||||||
|
) => void;
|
||||||
|
scriptRouteStore?: ScriptRouteStore;
|
||||||
|
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
||||||
|
}): {
|
||||||
session: Session;
|
session: Session;
|
||||||
emitted: Array<{ type: string; payload: unknown }>;
|
emitted: Array<{ type: string; payload: unknown }>;
|
||||||
projects: Map<string, ReturnType<typeof createPersistedProjectRecord>>;
|
projects: Map<string, ReturnType<typeof createPersistedProjectRecord>>;
|
||||||
@@ -221,6 +232,10 @@ function createSessionForWorkspaceGitWatchTests(): {
|
|||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
terminalManager: null,
|
terminalManager: null,
|
||||||
|
scriptRouteStore: options?.scriptRouteStore,
|
||||||
|
scriptRuntimeStore: options?.scriptRuntimeStore,
|
||||||
|
onBranchChanged: options?.onBranchChanged,
|
||||||
|
getDaemonTcpPort: () => 6767,
|
||||||
});
|
});
|
||||||
|
|
||||||
asInternals<SessionInternals>(session).listAgentPayloads = async () => [];
|
asInternals<SessionInternals>(session).listAgentPayloads = async () => [];
|
||||||
@@ -305,7 +320,7 @@ describe("workspace git watch targets", () => {
|
|||||||
|
|
||||||
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
|
||||||
|
|
||||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
|
||||||
|
|
||||||
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
|
||||||
{ cwd: REPO_CWD },
|
{ cwd: REPO_CWD },
|
||||||
@@ -364,7 +379,7 @@ describe("workspace git watch targets", () => {
|
|||||||
lastEmittedByWorkspaceId: new Map(),
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
|
||||||
emitted.length = 0;
|
emitted.length = 0;
|
||||||
|
|
||||||
subscriptions[0]?.listener(
|
subscriptions[0]?.listener(
|
||||||
@@ -407,6 +422,74 @@ describe("workspace git watch targets", () => {
|
|||||||
await session.cleanup();
|
await session.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("updates running service script URLs when the git branch changes", async () => {
|
||||||
|
const routeStore = new ScriptRouteStore();
|
||||||
|
routeStore.registerRoute({
|
||||||
|
hostname: "app.old-branch.paseo.localhost",
|
||||||
|
port: 4321,
|
||||||
|
workspaceId: "ws-10",
|
||||||
|
projectSlug: "paseo",
|
||||||
|
scriptName: "app",
|
||||||
|
});
|
||||||
|
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||||
|
runtimeStore.set({
|
||||||
|
workspaceId: "ws-10",
|
||||||
|
scriptName: "app",
|
||||||
|
type: "service",
|
||||||
|
lifecycle: "running",
|
||||||
|
terminalId: "term-app",
|
||||||
|
exitCode: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleBranchChange = createBranchChangeRouteHandler({
|
||||||
|
routeStore,
|
||||||
|
onRoutesChanged: vi.fn(),
|
||||||
|
});
|
||||||
|
const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(
|
||||||
|
{
|
||||||
|
scriptRouteStore: routeStore,
|
||||||
|
scriptRuntimeStore: runtimeStore,
|
||||||
|
onBranchChanged: handleBranchChange,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const sessionAny = session as unknown as SessionInternals;
|
||||||
|
seedGitWorkspace({
|
||||||
|
projects,
|
||||||
|
workspaces,
|
||||||
|
projectId: "proj-1",
|
||||||
|
workspaceId: "ws-10",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
name: "old-branch",
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true, workspaceId: "ws-10" });
|
||||||
|
|
||||||
|
subscriptions[0]?.listener(
|
||||||
|
createWorkspaceRuntimeSnapshot("/tmp/repo", {
|
||||||
|
git: {
|
||||||
|
currentBranch: "new-branch",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
hostname: "app.new-branch.paseo.localhost",
|
||||||
|
projectSlug: "paseo",
|
||||||
|
scriptName: "app",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
scriptName: "app",
|
||||||
|
hostname: "app.new-branch.paseo.localhost",
|
||||||
|
proxyUrl: "http://app.new-branch.paseo.localhost:6767",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await session.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
|
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
|
||||||
const { session, emitted, projects, workspaces, subscriptions } =
|
const { session, emitted, projects, workspaces, subscriptions } =
|
||||||
createSessionForWorkspaceGitWatchTests();
|
createSessionForWorkspaceGitWatchTests();
|
||||||
@@ -427,7 +510,7 @@ describe("workspace git watch targets", () => {
|
|||||||
lastEmittedByWorkspaceId: new Map(),
|
lastEmittedByWorkspaceId: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
|
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
|
||||||
emitted.length = 0;
|
emitted.length = 0;
|
||||||
|
|
||||||
subscriptions[0]?.listener(
|
subscriptions[0]?.listener(
|
||||||
|
|||||||
@@ -332,6 +332,18 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
|
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
|
||||||
private readonly serverId: string;
|
private readonly serverId: string;
|
||||||
private readonly daemonVersion: string;
|
private readonly daemonVersion: string;
|
||||||
|
private readonly daemonRuntimeConfig:
|
||||||
|
| {
|
||||||
|
listen: string | null;
|
||||||
|
relay: {
|
||||||
|
enabled: boolean;
|
||||||
|
endpoint: string;
|
||||||
|
publicEndpoint: string;
|
||||||
|
useTls: boolean;
|
||||||
|
publicUseTls: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
private readonly agentManager: AgentManager;
|
private readonly agentManager: AgentManager;
|
||||||
private readonly agentStorage: AgentStorage;
|
private readonly agentStorage: AgentStorage;
|
||||||
private readonly projectRegistry: ProjectRegistry;
|
private readonly projectRegistry: ProjectRegistry;
|
||||||
@@ -416,6 +428,16 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
workspaceGitService?: WorkspaceGitService,
|
workspaceGitService?: WorkspaceGitService,
|
||||||
github?: GitHubService,
|
github?: GitHubService,
|
||||||
pushNotificationSender?: PushNotificationSender,
|
pushNotificationSender?: PushNotificationSender,
|
||||||
|
daemonRuntimeConfig?: {
|
||||||
|
listen: string | null;
|
||||||
|
relay: {
|
||||||
|
enabled: boolean;
|
||||||
|
endpoint: string;
|
||||||
|
publicEndpoint: string;
|
||||||
|
useTls: boolean;
|
||||||
|
publicUseTls: boolean;
|
||||||
|
};
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
this.serverId = serverId;
|
this.serverId = serverId;
|
||||||
@@ -423,6 +445,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
throw new MissingDaemonVersionError();
|
throw new MissingDaemonVersionError();
|
||||||
}
|
}
|
||||||
this.daemonVersion = daemonVersion.trim();
|
this.daemonVersion = daemonVersion.trim();
|
||||||
|
this.daemonRuntimeConfig = daemonRuntimeConfig;
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentStorage = agentStorage;
|
this.agentStorage = agentStorage;
|
||||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||||
@@ -921,6 +944,9 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
|
||||||
providerOverrides: this.providerOverrides,
|
providerOverrides: this.providerOverrides,
|
||||||
isDev: this.isDev,
|
isDev: this.isDev,
|
||||||
|
serverId: this.serverId,
|
||||||
|
daemonVersion: this.daemonVersion,
|
||||||
|
daemonRuntimeConfig: this.daemonRuntimeConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
connection = {
|
connection = {
|
||||||
@@ -1053,6 +1079,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
providersSnapshot: true,
|
providersSnapshot: true,
|
||||||
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
|
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
|
||||||
checkoutGithubSetAutoMerge: true,
|
checkoutGithubSetAutoMerge: true,
|
||||||
|
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||||
|
daemonStatusRpc: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||||
|
|
||||||
|
type SessionMessageOption = z.ZodDiscriminatedUnionOption<"type">;
|
||||||
|
|
||||||
|
function schemaWithoutMessageTypes(
|
||||||
|
schema: { options: SessionMessageOption[] },
|
||||||
|
excludedTypes: string[],
|
||||||
|
) {
|
||||||
|
const excluded = new Set(excludedTypes);
|
||||||
|
const options = schema.options.filter((option) => !excluded.has(option.shape.type.value));
|
||||||
|
|
||||||
|
return z.discriminatedUnion("type", options as [SessionMessageOption, ...SessionMessageOption[]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("rename entity message schemas", () => {
|
||||||
|
test("new client schema still parses old daemon checkout and terminal responses", () => {
|
||||||
|
const checkoutResponse = SessionOutboundMessageSchema.parse({
|
||||||
|
type: "checkout_switch_branch_response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
success: true,
|
||||||
|
branch: "main",
|
||||||
|
source: "local",
|
||||||
|
error: null,
|
||||||
|
requestId: "request-switch",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const terminalResponse = SessionOutboundMessageSchema.parse({
|
||||||
|
type: "kill_terminal_response",
|
||||||
|
payload: {
|
||||||
|
terminalId: "terminal-1",
|
||||||
|
success: true,
|
||||||
|
requestId: "request-kill",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(checkoutResponse).toEqual({
|
||||||
|
type: "checkout_switch_branch_response",
|
||||||
|
payload: {
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
success: true,
|
||||||
|
branch: "main",
|
||||||
|
source: "local",
|
||||||
|
error: null,
|
||||||
|
requestId: "request-switch",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(terminalResponse).toEqual({
|
||||||
|
type: "kill_terminal_response",
|
||||||
|
payload: {
|
||||||
|
terminalId: "terminal-1",
|
||||||
|
success: true,
|
||||||
|
requestId: "request-kill",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("old unions without rename variants reject rename messages and still parse existing messages", () => {
|
||||||
|
const legacyInboundSchema = schemaWithoutMessageTypes(SessionInboundMessageSchema, [
|
||||||
|
"terminal.rename.request",
|
||||||
|
"checkout.rename_branch.request",
|
||||||
|
]);
|
||||||
|
const legacyOutboundSchema = schemaWithoutMessageTypes(SessionOutboundMessageSchema, [
|
||||||
|
"terminal.rename.response",
|
||||||
|
"checkout.rename_branch.response",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
legacyInboundSchema.safeParse({
|
||||||
|
type: "terminal.rename.request",
|
||||||
|
terminalId: "terminal-1",
|
||||||
|
title: "Server logs",
|
||||||
|
requestId: "request-terminal-rename",
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
legacyInboundSchema.safeParse({
|
||||||
|
type: "checkout.rename_branch.request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
branch: "feature/new-name",
|
||||||
|
requestId: "request-branch-rename",
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
legacyOutboundSchema.safeParse({
|
||||||
|
type: "terminal.rename.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "request-terminal-rename",
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
legacyOutboundSchema.safeParse({
|
||||||
|
type: "checkout.rename_branch.response",
|
||||||
|
payload: {
|
||||||
|
requestId: "request-branch-rename",
|
||||||
|
success: true,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
currentBranch: "feature/new-name",
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
legacyInboundSchema.parse({
|
||||||
|
type: "checkout_switch_branch_request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
branch: "main",
|
||||||
|
requestId: "request-switch",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "checkout_switch_branch_request",
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
branch: "main",
|
||||||
|
requestId: "request-switch",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
legacyOutboundSchema.parse({
|
||||||
|
type: "kill_terminal_response",
|
||||||
|
payload: {
|
||||||
|
terminalId: "terminal-1",
|
||||||
|
success: true,
|
||||||
|
requestId: "request-kill",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "kill_terminal_response",
|
||||||
|
payload: {
|
||||||
|
terminalId: "terminal-1",
|
||||||
|
success: true,
|
||||||
|
requestId: "request-kill",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -979,6 +979,16 @@ export const WaitForFinishRequestSchema = z.object({
|
|||||||
timeoutMs: z.number().int().positive().optional(),
|
timeoutMs: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const DaemonGetStatusRequestSchema = z.object({
|
||||||
|
type: z.literal("daemon.get_status.request"),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DaemonGetPairingOfferRequestSchema = z.object({
|
||||||
|
type: z.literal("daemon.get_pairing_offer.request"),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const GetDaemonConfigRequestMessageSchema = z.object({
|
export const GetDaemonConfigRequestMessageSchema = z.object({
|
||||||
type: z.literal("get_daemon_config_request"),
|
type: z.literal("get_daemon_config_request"),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -1379,6 +1389,13 @@ export const CheckoutSwitchBranchRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutRenameBranchRequestSchema = z.object({
|
||||||
|
type: z.literal("checkout.rename_branch.request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
branch: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const StashSaveRequestSchema = z.object({
|
export const StashSaveRequestSchema = z.object({
|
||||||
type: z.literal("stash_save_request"),
|
type: z.literal("stash_save_request"),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -1696,6 +1713,13 @@ export const CreateTerminalRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const RenameTerminalRequestSchema = z.object({
|
||||||
|
type: z.literal("terminal.rename.request"),
|
||||||
|
terminalId: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const StartWorkspaceScriptRequestSchema = z.object({
|
export const StartWorkspaceScriptRequestSchema = z.object({
|
||||||
type: z.literal("start_workspace_script_request"),
|
type: z.literal("start_workspace_script_request"),
|
||||||
workspaceId: z.string(),
|
workspaceId: z.string(),
|
||||||
@@ -1764,6 +1788,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetVoiceModeMessageSchema,
|
SetVoiceModeMessageSchema,
|
||||||
SendAgentMessageRequestSchema,
|
SendAgentMessageRequestSchema,
|
||||||
WaitForFinishRequestSchema,
|
WaitForFinishRequestSchema,
|
||||||
|
DaemonGetStatusRequestSchema,
|
||||||
|
DaemonGetPairingOfferRequestSchema,
|
||||||
GetDaemonConfigRequestMessageSchema,
|
GetDaemonConfigRequestMessageSchema,
|
||||||
SetDaemonConfigRequestMessageSchema,
|
SetDaemonConfigRequestMessageSchema,
|
||||||
ReadProjectConfigRequestMessageSchema,
|
ReadProjectConfigRequestMessageSchema,
|
||||||
@@ -1806,6 +1832,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPrStatusRequestSchema,
|
CheckoutPrStatusRequestSchema,
|
||||||
PullRequestTimelineRequestSchema,
|
PullRequestTimelineRequestSchema,
|
||||||
CheckoutSwitchBranchRequestSchema,
|
CheckoutSwitchBranchRequestSchema,
|
||||||
|
CheckoutRenameBranchRequestSchema,
|
||||||
StashSaveRequestSchema,
|
StashSaveRequestSchema,
|
||||||
StashPopRequestSchema,
|
StashPopRequestSchema,
|
||||||
StashListRequestSchema,
|
StashListRequestSchema,
|
||||||
@@ -1833,6 +1860,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SubscribeTerminalsRequestSchema,
|
SubscribeTerminalsRequestSchema,
|
||||||
UnsubscribeTerminalsRequestSchema,
|
UnsubscribeTerminalsRequestSchema,
|
||||||
CreateTerminalRequestSchema,
|
CreateTerminalRequestSchema,
|
||||||
|
RenameTerminalRequestSchema,
|
||||||
StartWorkspaceScriptRequestSchema,
|
StartWorkspaceScriptRequestSchema,
|
||||||
SubscribeTerminalRequestSchema,
|
SubscribeTerminalRequestSchema,
|
||||||
UnsubscribeTerminalRequestSchema,
|
UnsubscribeTerminalRequestSchema,
|
||||||
@@ -2026,6 +2054,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
.object({
|
.object({
|
||||||
providersSnapshot: z.boolean().optional(),
|
providersSnapshot: z.boolean().optional(),
|
||||||
checkoutGithubSetAutoMerge: z.boolean().optional(),
|
checkoutGithubSetAutoMerge: z.boolean().optional(),
|
||||||
|
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||||
|
daemonStatusRpc: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
@@ -2586,6 +2616,50 @@ export const GetDaemonConfigResponseMessageSchema = z.object({
|
|||||||
.passthrough(),
|
.passthrough(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const DaemonGetStatusResponseSchema = z.object({
|
||||||
|
type: z.literal("daemon.get_status.response"),
|
||||||
|
payload: z
|
||||||
|
.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
serverId: z.string(),
|
||||||
|
version: z.string().nullable().optional(),
|
||||||
|
pid: z.number(),
|
||||||
|
nodePath: z.string(),
|
||||||
|
startedAt: z.string().nullable().optional(),
|
||||||
|
listen: z.string().nullable(),
|
||||||
|
relay: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
endpoint: z.string(),
|
||||||
|
publicEndpoint: z.string(),
|
||||||
|
useTls: z.boolean(),
|
||||||
|
publicUseTls: z.boolean(),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
providers: z.array(
|
||||||
|
z.object({
|
||||||
|
provider: z.string(),
|
||||||
|
available: z.boolean(),
|
||||||
|
error: z.string().nullable().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DaemonGetPairingOfferResponseSchema = z.object({
|
||||||
|
type: z.literal("daemon.get_pairing_offer.response"),
|
||||||
|
payload: z
|
||||||
|
.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
qr: z.string().nullable().optional(),
|
||||||
|
relayEnabled: z.boolean(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
});
|
||||||
|
|
||||||
export const SetDaemonConfigResponseMessageSchema = z.object({
|
export const SetDaemonConfigResponseMessageSchema = z.object({
|
||||||
type: z.literal("set_daemon_config_response"),
|
type: z.literal("set_daemon_config_response"),
|
||||||
payload: z
|
payload: z
|
||||||
@@ -3047,6 +3121,17 @@ export const CheckoutSwitchBranchResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CheckoutRenameBranchResponseSchema = z.object({
|
||||||
|
type: z.literal("checkout.rename_branch.response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
cwd: z.string(),
|
||||||
|
currentBranch: z.string().nullable(),
|
||||||
|
error: CheckoutErrorSchema.nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const StashEntrySchema = z.object({
|
const StashEntrySchema = z.object({
|
||||||
index: z.number().int().min(0),
|
index: z.number().int().min(0),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
@@ -3401,6 +3486,15 @@ export const CreateTerminalResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const RenameTerminalResponseSchema = z.object({
|
||||||
|
type: z.literal("terminal.rename.response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const SubscribeTerminalResponseSchema = z.object({
|
export const SubscribeTerminalResponseSchema = z.object({
|
||||||
type: z.literal("subscribe_terminal_response"),
|
type: z.literal("subscribe_terminal_response"),
|
||||||
payload: z.union([
|
payload: z.union([
|
||||||
@@ -3481,6 +3575,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ClearAgentAttentionResponseMessageSchema,
|
ClearAgentAttentionResponseMessageSchema,
|
||||||
SendAgentMessageResponseMessageSchema,
|
SendAgentMessageResponseMessageSchema,
|
||||||
SetVoiceModeResponseMessageSchema,
|
SetVoiceModeResponseMessageSchema,
|
||||||
|
DaemonGetStatusResponseSchema,
|
||||||
|
DaemonGetPairingOfferResponseSchema,
|
||||||
GetDaemonConfigResponseMessageSchema,
|
GetDaemonConfigResponseMessageSchema,
|
||||||
SetDaemonConfigResponseMessageSchema,
|
SetDaemonConfigResponseMessageSchema,
|
||||||
ReadProjectConfigResponseMessageSchema,
|
ReadProjectConfigResponseMessageSchema,
|
||||||
@@ -3512,6 +3608,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutPrStatusResponseSchema,
|
CheckoutPrStatusResponseSchema,
|
||||||
PullRequestTimelineResponseSchema,
|
PullRequestTimelineResponseSchema,
|
||||||
CheckoutSwitchBranchResponseSchema,
|
CheckoutSwitchBranchResponseSchema,
|
||||||
|
CheckoutRenameBranchResponseSchema,
|
||||||
StashSaveResponseSchema,
|
StashSaveResponseSchema,
|
||||||
StashPopResponseSchema,
|
StashPopResponseSchema,
|
||||||
StashListResponseSchema,
|
StashListResponseSchema,
|
||||||
@@ -3537,6 +3634,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListTerminalsResponseSchema,
|
ListTerminalsResponseSchema,
|
||||||
TerminalsChangedSchema,
|
TerminalsChangedSchema,
|
||||||
CreateTerminalResponseSchema,
|
CreateTerminalResponseSchema,
|
||||||
|
RenameTerminalResponseSchema,
|
||||||
SubscribeTerminalResponseSchema,
|
SubscribeTerminalResponseSchema,
|
||||||
KillTerminalResponseSchema,
|
KillTerminalResponseSchema,
|
||||||
CaptureTerminalResponseSchema,
|
CaptureTerminalResponseSchema,
|
||||||
@@ -3643,6 +3741,8 @@ export type ListProviderFeaturesResponseMessage = z.infer<
|
|||||||
typeof ListProviderFeaturesResponseMessageSchema
|
typeof ListProviderFeaturesResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
|
||||||
|
export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>;
|
||||||
|
export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>;
|
||||||
export type GetProvidersSnapshotResponseMessage = z.infer<
|
export type GetProvidersSnapshotResponseMessage = z.infer<
|
||||||
typeof GetProvidersSnapshotResponseMessageSchema
|
typeof GetProvidersSnapshotResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
@@ -3782,6 +3882,8 @@ export type PullRequestTimelineItem = z.infer<typeof PullRequestTimelineItemSche
|
|||||||
export type PullRequestTimelineResponse = z.infer<typeof PullRequestTimelineResponseSchema>;
|
export type PullRequestTimelineResponse = z.infer<typeof PullRequestTimelineResponseSchema>;
|
||||||
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
|
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
|
||||||
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
|
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
|
||||||
|
export type CheckoutRenameBranchRequest = z.infer<typeof CheckoutRenameBranchRequestSchema>;
|
||||||
|
export type CheckoutRenameBranchResponse = z.infer<typeof CheckoutRenameBranchResponseSchema>;
|
||||||
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
|
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
|
||||||
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
|
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
|
||||||
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
|
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
|
||||||
@@ -3835,6 +3937,8 @@ export type UnsubscribeTerminalsRequest = z.infer<typeof UnsubscribeTerminalsReq
|
|||||||
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
|
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
|
||||||
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
|
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
|
||||||
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
|
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
|
||||||
|
export type RenameTerminalRequest = z.infer<typeof RenameTerminalRequestSchema>;
|
||||||
|
export type RenameTerminalResponse = z.infer<typeof RenameTerminalResponseSchema>;
|
||||||
export type StartWorkspaceScriptRequest = z.infer<typeof StartWorkspaceScriptRequestSchema>;
|
export type StartWorkspaceScriptRequest = z.infer<typeof StartWorkspaceScriptRequestSchema>;
|
||||||
export type StartWorkspaceScriptResponse = z.infer<
|
export type StartWorkspaceScriptResponse = z.infer<
|
||||||
typeof StartWorkspaceScriptResponseMessageSchema
|
typeof StartWorkspaceScriptResponseMessageSchema
|
||||||
|
|||||||
@@ -384,3 +384,35 @@ it("emits empty snapshot when last terminal is removed", async () => {
|
|||||||
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => {
|
||||||
|
manager = createTerminalManager();
|
||||||
|
const session = await manager.createTerminal({
|
||||||
|
cwd: realpathSync(tmpdir()),
|
||||||
|
title: "Existing title",
|
||||||
|
});
|
||||||
|
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
|
||||||
|
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||||
|
snapshots.push(
|
||||||
|
input.terminals.map((terminal) => ({
|
||||||
|
id: terminal.id,
|
||||||
|
...(terminal.title ? { title: terminal.title } : {}),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false);
|
||||||
|
expect(session.getTitle()).toBe("Existing title");
|
||||||
|
expect(session.getState().title).toBe("Existing title");
|
||||||
|
expect(snapshots).toEqual([]);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => {
|
||||||
|
manager = createTerminalManager();
|
||||||
|
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
|
||||||
|
|
||||||
|
expect(manager.setTerminalTitle(session.id, "x")).toBe(true);
|
||||||
|
expect(session.getTitle()).toBe("x");
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface TerminalManager {
|
|||||||
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
||||||
getTerminal(id: string): TerminalSession | undefined;
|
getTerminal(id: string): TerminalSession | undefined;
|
||||||
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
|
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
|
||||||
|
setTerminalTitle(id: string, title: string): boolean;
|
||||||
killTerminal(id: string): void;
|
killTerminal(id: string): void;
|
||||||
killTerminalAndWait(
|
killTerminalAndWait(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -211,6 +212,16 @@ export function createTerminalManager(): TerminalManager {
|
|||||||
return terminalsById.get(id)?.getStateSnapshot() ?? null;
|
return terminalsById.get(id)?.getStateSnapshot() ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setTerminalTitle(id: string, title: string): boolean {
|
||||||
|
const session = terminalsById.get(id);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.setTitle(title);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
killTerminal(id: string): void {
|
killTerminal(id: string): void {
|
||||||
removeSessionById(id, { kill: true });
|
removeSessionById(id, { kill: true });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CreateTerminalRequest,
|
CreateTerminalRequest,
|
||||||
KillTerminalRequest,
|
KillTerminalRequest,
|
||||||
ListTerminalsRequest,
|
ListTerminalsRequest,
|
||||||
|
RenameTerminalRequest,
|
||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
SubscribeTerminalRequest,
|
SubscribeTerminalRequest,
|
||||||
@@ -64,7 +65,8 @@ type TerminalDispatchableMessage =
|
|||||||
| UnsubscribeTerminalRequest
|
| UnsubscribeTerminalRequest
|
||||||
| TerminalInput
|
| TerminalInput
|
||||||
| KillTerminalRequest
|
| KillTerminalRequest
|
||||||
| CaptureTerminalRequest;
|
| CaptureTerminalRequest
|
||||||
|
| RenameTerminalRequest;
|
||||||
|
|
||||||
const TERMINAL_MESSAGE_TYPES: ReadonlySet<TerminalDispatchableMessage["type"]> = new Set([
|
const TERMINAL_MESSAGE_TYPES: ReadonlySet<TerminalDispatchableMessage["type"]> = new Set([
|
||||||
"subscribe_terminals_request",
|
"subscribe_terminals_request",
|
||||||
@@ -76,6 +78,7 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet<TerminalDispatchableMessage["type"]> =
|
|||||||
"terminal_input",
|
"terminal_input",
|
||||||
"kill_terminal_request",
|
"kill_terminal_request",
|
||||||
"capture_terminal_request",
|
"capture_terminal_request",
|
||||||
|
"terminal.rename.request",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export class TerminalSessionController {
|
export class TerminalSessionController {
|
||||||
@@ -145,6 +148,8 @@ export class TerminalSessionController {
|
|||||||
return this.handleKillTerminalRequest(msg);
|
return this.handleKillTerminalRequest(msg);
|
||||||
case "capture_terminal_request":
|
case "capture_terminal_request":
|
||||||
return this.handleCaptureTerminalRequest(msg);
|
return this.handleCaptureTerminalRequest(msg);
|
||||||
|
case "terminal.rename.request":
|
||||||
|
return this.handleRenameTerminalRequest(msg);
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -430,6 +435,32 @@ export class TerminalSessionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise<void> {
|
||||||
|
const respond = (success: boolean, error: string | null): void => {
|
||||||
|
this.emit({
|
||||||
|
type: "terminal.rename.response",
|
||||||
|
payload: { requestId: msg.requestId, success, error },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = msg.title.trim();
|
||||||
|
if (title.length === 0) {
|
||||||
|
respond(false, "Title is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (title.length > 200) {
|
||||||
|
respond(false, "Title is too long");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.terminalManager) {
|
||||||
|
respond(false, "Terminal manager not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renamed = this.terminalManager.setTerminalTitle(msg.terminalId, title);
|
||||||
|
respond(renamed, renamed ? null : "Terminal not found");
|
||||||
|
}
|
||||||
|
|
||||||
private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise<void> {
|
private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise<void> {
|
||||||
if (!this.terminalManager) {
|
if (!this.terminalManager) {
|
||||||
this.emit({
|
this.emit({
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { describe, it, expect, afterEach } from "vitest";
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
import {
|
import {
|
||||||
|
buildTerminalEnvironment,
|
||||||
createTerminal,
|
createTerminal,
|
||||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
|
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
|
||||||
resolveDefaultTerminalShell,
|
resolveDefaultTerminalShell,
|
||||||
humanizeProcessTitle,
|
humanizeProcessTitle,
|
||||||
normalizeProcessTitle,
|
normalizeProcessTitle,
|
||||||
|
resolveZshShellIntegrationDir,
|
||||||
type TerminalSession,
|
type TerminalSession,
|
||||||
} from "./terminal.js";
|
} from "./terminal.js";
|
||||||
import {
|
import {
|
||||||
chmodSync,
|
chmodSync,
|
||||||
|
cpSync,
|
||||||
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
realpathSync,
|
realpathSync,
|
||||||
@@ -17,8 +21,92 @@ import {
|
|||||||
statSync,
|
statSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
|
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||||
|
|
||||||
|
const hasZsh = existsSync("/bin/zsh");
|
||||||
|
|
||||||
|
type TerminalRow = ReturnType<TerminalSession["getState"]>["grid"][number];
|
||||||
|
|
||||||
|
function rowToText(row: TerminalRow): string {
|
||||||
|
return row
|
||||||
|
.map((cell) => cell.char)
|
||||||
|
.join("")
|
||||||
|
.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from a single row
|
||||||
|
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
|
||||||
|
return rowToText(state.grid[rowIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract all visible lines as array (trimmed, empty lines included)
|
||||||
|
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
|
||||||
|
return state.grid.map(rowToText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for terminal state to match expected lines
|
||||||
|
async function waitForLines(
|
||||||
|
session: TerminalSession,
|
||||||
|
expectedLines: string[],
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const lines = getLines(session.getState());
|
||||||
|
let matches = true;
|
||||||
|
for (let i = 0; i < expectedLines.length; i++) {
|
||||||
|
if (lines[i] !== expectedLines[i]) {
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
const actual = getLines(session.getState()).slice(0, expectedLines.length);
|
||||||
|
throw new Error(
|
||||||
|
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForState(
|
||||||
|
session: TerminalSession,
|
||||||
|
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<ReturnType<TerminalSession["getState"]>> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const state = session.getState();
|
||||||
|
if (predicate(state)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Timeout waiting for terminal state predicate to match");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTitle(
|
||||||
|
session: TerminalSession,
|
||||||
|
predicate: (title: string | undefined) => boolean,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const title = session.getTitle();
|
||||||
|
if (predicate(title)) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Timeout waiting for terminal title predicate to match");
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
|
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
|
||||||
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
|
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
|
||||||
@@ -28,6 +116,7 @@ const sessions: TerminalSession[] = [];
|
|||||||
const temporaryDirs: string[] = [];
|
const temporaryDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
session.kill();
|
session.kill();
|
||||||
}
|
}
|
||||||
@@ -45,6 +134,17 @@ function trackSession(session: TerminalSession): TerminalSession {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForScheduledTimers(expectedTimerCount: number): Promise<void> {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
if (vi.getTimerCount() === expectedTimerCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await waitForImmediate();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Expected ${expectedTimerCount} scheduled timers, got ${vi.getTimerCount()}`);
|
||||||
|
}
|
||||||
|
|
||||||
describe("createTerminal", () => {
|
describe("createTerminal", () => {
|
||||||
it("keeps full process titles while stripping path prefixes", () => {
|
it("keeps full process titles while stripping path prefixes", () => {
|
||||||
expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev");
|
expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev");
|
||||||
@@ -205,6 +305,544 @@ describe("createTerminal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.skipIf(isPlatform("win32"))("send input", () => {
|
||||||
|
it("executes a simple echo command", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for initial prompt, then send command
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
|
||||||
|
session.send({ type: "input", data: "echo hello\r" });
|
||||||
|
|
||||||
|
// After running "echo hello", terminal should show:
|
||||||
|
// Line 0: "$ echo hello"
|
||||||
|
// Line 1: "hello"
|
||||||
|
// Line 2: "$"
|
||||||
|
await waitForLines(session, ["$ echo hello", "hello", "$"]);
|
||||||
|
|
||||||
|
const state = session.getState();
|
||||||
|
expect(getRowText(state, 0)).toBe("$ echo hello");
|
||||||
|
expect(getRowText(state, 1)).toBe("hello");
|
||||||
|
expect(getRowText(state, 2)).toBe("$");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures output from pwd in specified cwd", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
|
||||||
|
session.send({ type: "input", data: "pwd\r" });
|
||||||
|
|
||||||
|
await waitForLines(session, ["$ pwd", "/tmp", "$"]);
|
||||||
|
|
||||||
|
const state = session.getState();
|
||||||
|
expect(getRowText(state, 0)).toBe("$ pwd");
|
||||||
|
expect(getRowText(state, 1)).toBe("/tmp");
|
||||||
|
expect(getRowText(state, 2)).toBe("$");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(isPlatform("win32"))("terminal title", () => {
|
||||||
|
it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => {
|
||||||
|
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-"));
|
||||||
|
temporaryDirs.push(homeDir);
|
||||||
|
const realZdotdir = join(homeDir, ".config", "zsh");
|
||||||
|
mkdirSync(realZdotdir, { recursive: true });
|
||||||
|
writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n");
|
||||||
|
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: homeDir,
|
||||||
|
command: "/bin/zsh",
|
||||||
|
args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'],
|
||||||
|
env: {
|
||||||
|
HOME: homeDir,
|
||||||
|
ZDOTDIR: realZdotdir,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
|
||||||
|
(resolve) => {
|
||||||
|
session.onExit((info) => resolve(info));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the initial title from command args to title listeners", async () => {
|
||||||
|
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-"));
|
||||||
|
temporaryDirs.push(packageRoot);
|
||||||
|
const scriptPath = join(packageRoot, "npm-cli.js");
|
||||||
|
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
|
||||||
|
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: packageRoot,
|
||||||
|
command: process.execPath,
|
||||||
|
args: [scriptPath, "run", "dev"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const seenTitles: Array<string | undefined> = [];
|
||||||
|
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||||
|
seenTitles.push(title);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForTitle(session, (title) => title === "npm run dev");
|
||||||
|
await waitForState(session, (state) => state.title === "npm run dev");
|
||||||
|
|
||||||
|
expect(seenTitles).toContain("npm run dev");
|
||||||
|
expect(session.getTitle()).toBe("npm run dev");
|
||||||
|
expect(session.getState().title).toBe("npm run dev");
|
||||||
|
|
||||||
|
unsubscribeTitle();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits OSC title updates to title listeners", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const seenTitles: Array<string | undefined> = [];
|
||||||
|
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||||
|
seenTitles.push(title);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||||
|
|
||||||
|
await waitForTitle(session, (title) => title === "Build Log");
|
||||||
|
|
||||||
|
expect(seenTitles).toContain("Build Log");
|
||||||
|
expect(session.getTitle()).toBe("Build Log");
|
||||||
|
expect(session.getState().title).toBe("Build Log");
|
||||||
|
|
||||||
|
unsubscribeTitle();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps preset titles instead of applying OSC title updates", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
title: "typecheck",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
expect(session.getTitle()).toBe("typecheck");
|
||||||
|
expect(session.getState().title).toBe("typecheck");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits command completion from VS Code OSC 633 without visible output", async () => {
|
||||||
|
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-"));
|
||||||
|
temporaryDirs.push(packageRoot);
|
||||||
|
const scriptPath = join(packageRoot, "emit-command-finished.sh");
|
||||||
|
writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n");
|
||||||
|
chmodSync(scriptPath, 0o755);
|
||||||
|
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: packageRoot,
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const commandCompletions: Array<number | null> = [];
|
||||||
|
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
|
||||||
|
commandCompletions.push(info.exitCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
session.send({ type: "input", data: "./emit-command-finished.sh\r" });
|
||||||
|
|
||||||
|
await waitForState(session, () => commandCompletions.length === 1);
|
||||||
|
|
||||||
|
expect(commandCompletions).toEqual([7]);
|
||||||
|
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7");
|
||||||
|
|
||||||
|
unsubscribeCommandFinished();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed VS Code OSC 633 command completion payloads", async () => {
|
||||||
|
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-"));
|
||||||
|
temporaryDirs.push(packageRoot);
|
||||||
|
const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh");
|
||||||
|
writeFileSync(
|
||||||
|
scriptPath,
|
||||||
|
"#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n",
|
||||||
|
);
|
||||||
|
chmodSync(scriptPath, 0o755);
|
||||||
|
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: packageRoot,
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const commandCompletions: Array<number | null> = [];
|
||||||
|
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
|
||||||
|
commandCompletions.push(info.exitCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" });
|
||||||
|
|
||||||
|
await waitForState(session, () => commandCompletions.length === 1);
|
||||||
|
|
||||||
|
expect(commandCompletions).toEqual([3]);
|
||||||
|
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage");
|
||||||
|
|
||||||
|
unsubscribeCommandFinished();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces rapid title changes and emits only the final title", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const seenTitles: Array<string | undefined> = [];
|
||||||
|
const seenMessages: Array<string | undefined> = [];
|
||||||
|
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||||
|
seenTitles.push(title);
|
||||||
|
});
|
||||||
|
const unsubscribeMessages = session.subscribe((message) => {
|
||||||
|
if (message.type === "titleChange") {
|
||||||
|
seenMessages.push(message.title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
session.send({
|
||||||
|
type: "input",
|
||||||
|
data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r",
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForTitle(session, (title) => title === "Final");
|
||||||
|
|
||||||
|
expect(seenTitles).toEqual(["Final"]);
|
||||||
|
expect(seenMessages).toEqual(["Final"]);
|
||||||
|
|
||||||
|
unsubscribeMessages();
|
||||||
|
unsubscribeTitle();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => {
|
||||||
|
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-"));
|
||||||
|
temporaryDirs.push(homeDir);
|
||||||
|
const realZdotdir = join(homeDir, ".config", "zsh");
|
||||||
|
const workingDir = join(homeDir, "dev", "faro");
|
||||||
|
mkdirSync(realZdotdir, { recursive: true });
|
||||||
|
mkdirSync(workingDir, { recursive: true });
|
||||||
|
writeFileSync(join(realZdotdir, ".zshenv"), "");
|
||||||
|
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
|
||||||
|
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: workingDir,
|
||||||
|
shell: "/bin/zsh",
|
||||||
|
env: {
|
||||||
|
HOME: homeDir,
|
||||||
|
ZDOTDIR: realZdotdir,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
await waitForTitle(session, (title) => title === "~/dev/faro");
|
||||||
|
|
||||||
|
session.send({ type: "input", data: "sleep 1\r" });
|
||||||
|
|
||||||
|
await waitForTitle(session, (title) => title === "sleep 1");
|
||||||
|
await waitForTitle(session, (title) => title === "~/dev/faro", 4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => {
|
||||||
|
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-"));
|
||||||
|
temporaryDirs.push(homeDir);
|
||||||
|
writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n");
|
||||||
|
|
||||||
|
const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources");
|
||||||
|
const inaccessiblePackagedIntegrationDir = join(
|
||||||
|
fakeAppRoot,
|
||||||
|
"app.asar",
|
||||||
|
"node_modules",
|
||||||
|
"@getpaseo",
|
||||||
|
"server",
|
||||||
|
"dist",
|
||||||
|
"server",
|
||||||
|
"terminal",
|
||||||
|
"shell-integration",
|
||||||
|
"zsh",
|
||||||
|
);
|
||||||
|
const unpackedIntegrationDir = join(
|
||||||
|
fakeAppRoot,
|
||||||
|
"app.asar.unpacked",
|
||||||
|
"node_modules",
|
||||||
|
"@getpaseo",
|
||||||
|
"server",
|
||||||
|
"dist",
|
||||||
|
"server",
|
||||||
|
"terminal",
|
||||||
|
"shell-integration",
|
||||||
|
"zsh",
|
||||||
|
);
|
||||||
|
mkdirSync(unpackedIntegrationDir, { recursive: true });
|
||||||
|
cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true });
|
||||||
|
writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder");
|
||||||
|
|
||||||
|
const env = buildTerminalEnvironment({
|
||||||
|
shell: "/bin/zsh",
|
||||||
|
env: {
|
||||||
|
HOME: homeDir,
|
||||||
|
},
|
||||||
|
zshShellIntegrationDir: inaccessiblePackagedIntegrationDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], {
|
||||||
|
cwd: homeDir,
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => {
|
||||||
|
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-"));
|
||||||
|
temporaryDirs.push(homeDir);
|
||||||
|
const realZdotdir = join(homeDir, ".config", "zsh");
|
||||||
|
const workingDir = join(homeDir, "dev", "faro");
|
||||||
|
mkdirSync(realZdotdir, { recursive: true });
|
||||||
|
mkdirSync(workingDir, { recursive: true });
|
||||||
|
writeFileSync(join(realZdotdir, ".zshenv"), "");
|
||||||
|
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
|
||||||
|
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: workingDir,
|
||||||
|
shell: "/bin/zsh",
|
||||||
|
env: {
|
||||||
|
HOME: homeDir,
|
||||||
|
ZDOTDIR: realZdotdir,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const commandCompletions: Array<number | null> = [];
|
||||||
|
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
|
||||||
|
commandCompletions.push(info.exitCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
session.send({ type: "input", data: "false\r" });
|
||||||
|
|
||||||
|
await waitForState(session, () => commandCompletions.includes(1));
|
||||||
|
|
||||||
|
expect(commandCompletions).toEqual([1]);
|
||||||
|
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1");
|
||||||
|
|
||||||
|
unsubscribeCommandFinished();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears already scheduled OSC title debounce timers when setting a user title", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const seenTitles: Array<string | undefined> = [];
|
||||||
|
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||||
|
seenTitles.push(title);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||||
|
|
||||||
|
await waitForTitle(session, (title) => title === "Build Log");
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
session.send({ type: "input", data: "printf '\\033]0;Pending Shell Title\\007'\r" });
|
||||||
|
await waitForScheduledTimers(1);
|
||||||
|
|
||||||
|
session.setTitle("User terminal");
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
|
||||||
|
expect(session.getTitle()).toBe("User terminal");
|
||||||
|
expect(session.getState().title).toBe("User terminal");
|
||||||
|
|
||||||
|
unsubscribeTitle();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores later OSC title updates after setting a user title", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const seenTitles: Array<string | undefined> = [];
|
||||||
|
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||||
|
seenTitles.push(title);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||||
|
|
||||||
|
await waitForTitle(session, (title) => title === "Build Log");
|
||||||
|
|
||||||
|
session.setTitle("User terminal");
|
||||||
|
session.send({ type: "input", data: "printf '\\033]0;Later Shell Title\\007'\r" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
|
||||||
|
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
|
||||||
|
expect(session.getTitle()).toBe("User terminal");
|
||||||
|
expect(session.getState().title).toBe("User terminal");
|
||||||
|
|
||||||
|
unsubscribeTitle();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims user-set titles and treats empty titles as no-ops", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const seenTitles: Array<string | undefined> = [];
|
||||||
|
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||||
|
seenTitles.push(title);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
|
||||||
|
session.setTitle(" ");
|
||||||
|
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||||
|
await waitForTitle(session, (title) => title === "Build Log");
|
||||||
|
|
||||||
|
session.setTitle(" User terminal ");
|
||||||
|
|
||||||
|
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
|
||||||
|
expect(session.getTitle()).toBe("User terminal");
|
||||||
|
expect(session.getState().title).toBe("User terminal");
|
||||||
|
|
||||||
|
unsubscribeTitle();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(isPlatform("win32"))("colors", () => {
|
||||||
|
it("captures ANSI 16 color codes (mode 1)", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
|
||||||
|
// \033[31m = ANSI red (color 1)
|
||||||
|
session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" });
|
||||||
|
|
||||||
|
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
|
||||||
|
|
||||||
|
const state = session.getState();
|
||||||
|
const outputRow = state.grid[1];
|
||||||
|
|
||||||
|
expect(outputRow[0].char).toBe("R");
|
||||||
|
expect(outputRow[0].fg).toBe(1); // ANSI red = 1
|
||||||
|
expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors
|
||||||
|
|
||||||
|
// The "$" after RED should have default color
|
||||||
|
expect(outputRow[3].char).toBe("$");
|
||||||
|
expect(outputRow[3].fg).toBe(undefined);
|
||||||
|
expect(outputRow[3].fgMode).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures true color RGB (mode 3)", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
|
||||||
|
// \033[38;2;255;128;64m = true color RGB(255, 128, 64)
|
||||||
|
session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" });
|
||||||
|
|
||||||
|
await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]);
|
||||||
|
|
||||||
|
const state = session.getState();
|
||||||
|
const outputRow = state.grid[1];
|
||||||
|
|
||||||
|
// Check R cell
|
||||||
|
expect(outputRow[0].char).toBe("R");
|
||||||
|
expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color
|
||||||
|
|
||||||
|
// The color value should be packed RGB: (255 << 16) | (128 << 8) | 64
|
||||||
|
const expectedPacked = (255 << 16) | (128 << 8) | 64;
|
||||||
|
expect(outputRow[0].fg).toBe(expectedPacked);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures background colors", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForLines(session, ["$"]);
|
||||||
|
|
||||||
|
// \033[41m = ANSI red background
|
||||||
|
session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" });
|
||||||
|
|
||||||
|
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
|
||||||
|
|
||||||
|
const state = session.getState();
|
||||||
|
const outputRow = state.grid[1];
|
||||||
|
|
||||||
|
expect(outputRow[0].char).toBe("B");
|
||||||
|
expect(outputRow[0].bg).toBe(1); // ANSI red = 1
|
||||||
|
expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resize", () => {
|
describe("resize", () => {
|
||||||
it("updates terminal dimensions on resize", async () => {
|
it("updates terminal dimensions on resize", async () => {
|
||||||
const session = trackSession(
|
const session = trackSession(
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface TerminalSession {
|
|||||||
getStateSnapshot(): TerminalStateSnapshot;
|
getStateSnapshot(): TerminalStateSnapshot;
|
||||||
getReplayPreamble(): string;
|
getReplayPreamble(): string;
|
||||||
getTitle(): string | undefined;
|
getTitle(): string | undefined;
|
||||||
|
setTitle(title: string): void;
|
||||||
getExitInfo(): TerminalExitInfo | null;
|
getExitInfo(): TerminalExitInfo | null;
|
||||||
kill(): void;
|
kill(): void;
|
||||||
killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise<void>;
|
killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise<void>;
|
||||||
@@ -549,12 +550,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
let exitInfo: TerminalExitInfo | null = null;
|
let exitInfo: TerminalExitInfo | null = null;
|
||||||
let recentOutputText = "";
|
let recentOutputText = "";
|
||||||
let title: string | undefined;
|
let title: string | undefined;
|
||||||
|
let titleMode: "auto" | "manual" = presetTitle?.trim() ? "manual" : "auto";
|
||||||
let pendingTitle: string | undefined;
|
let pendingTitle: string | undefined;
|
||||||
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pendingInput = "";
|
let pendingInput = "";
|
||||||
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
|
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
|
||||||
let stateRevision = 0;
|
let stateRevision = 0;
|
||||||
const inputModeTracker = new TerminalInputModeTracker();
|
const inputModeTracker = new TerminalInputModeTracker();
|
||||||
|
let titleChangeSubscription: { dispose(): void } | null = null;
|
||||||
|
|
||||||
// Create xterm.js headless terminal
|
// Create xterm.js headless terminal
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
@@ -598,14 +601,40 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lockedTitle = presetTitle?.trim() || undefined;
|
function clearPendingTitleChange(): void {
|
||||||
|
pendingTitle = undefined;
|
||||||
|
if (titleDebounceTimer) {
|
||||||
|
clearTimeout(titleDebounceTimer);
|
||||||
|
titleDebounceTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeTitleChangeSubscription(): void {
|
||||||
|
titleChangeSubscription?.dispose();
|
||||||
|
titleChangeSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitle(nextTitle: string): void {
|
||||||
|
const manualTitle = nextTitle.trim();
|
||||||
|
if (!manualTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
titleMode = "manual";
|
||||||
|
disposeTitleChangeSubscription();
|
||||||
|
clearPendingTitleChange();
|
||||||
|
emitTitleChange(manualTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialManualTitle = presetTitle?.trim() || undefined;
|
||||||
const processTitle = command ? [command, ...args].join(" ") : null;
|
const processTitle = command ? [command, ...args].join(" ") : null;
|
||||||
let initialTitle = lockedTitle;
|
let initialTitle = initialManualTitle;
|
||||||
if (!initialTitle && processTitle) {
|
if (!initialTitle && processTitle) {
|
||||||
initialTitle = humanizeProcessTitle(processTitle) ?? normalizeProcessTitle(processTitle);
|
initialTitle = humanizeProcessTitle(processTitle) ?? normalizeProcessTitle(processTitle);
|
||||||
}
|
}
|
||||||
emitTitleChange(initialTitle);
|
emitTitleChange(initialTitle);
|
||||||
|
|
||||||
|
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
|
||||||
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
|
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
|
||||||
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
|
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
|
||||||
ptyProcess.write("\x1b[?62;4;22c");
|
ptyProcess.write("\x1b[?62;4;22c");
|
||||||
@@ -637,9 +666,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
let disposeTitleChangeSubscription: { dispose(): void } | null = null;
|
if (titleMode === "auto") {
|
||||||
if (!lockedTitle) {
|
titleChangeSubscription = terminal.onTitleChange((nextTitle) => {
|
||||||
disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {
|
|
||||||
if (disposed || killed) {
|
if (disposed || killed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -650,6 +678,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
titleDebounceTimer = setTimeout(() => {
|
titleDebounceTimer = setTimeout(() => {
|
||||||
titleDebounceTimer = null;
|
titleDebounceTimer = null;
|
||||||
emitTitleChange(pendingTitle);
|
emitTitleChange(pendingTitle);
|
||||||
|
pendingTitle = undefined;
|
||||||
}, TERMINAL_TITLE_DEBOUNCE_MS);
|
}, TERMINAL_TITLE_DEBOUNCE_MS);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -712,11 +741,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
clearImmediate(inputFlushImmediate);
|
clearImmediate(inputFlushImmediate);
|
||||||
inputFlushImmediate = null;
|
inputFlushImmediate = null;
|
||||||
}
|
}
|
||||||
if (titleDebounceTimer) {
|
clearPendingTitleChange();
|
||||||
clearTimeout(titleDebounceTimer);
|
disposeTitleChangeSubscription();
|
||||||
titleDebounceTimer = null;
|
|
||||||
}
|
|
||||||
disposeTitleChangeSubscription?.dispose();
|
|
||||||
disposeCommandLifecycleSubscription.dispose();
|
disposeCommandLifecycleSubscription.dispose();
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
@@ -1049,6 +1075,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
getStateSnapshot,
|
getStateSnapshot,
|
||||||
getReplayPreamble,
|
getReplayPreamble,
|
||||||
getTitle,
|
getTitle,
|
||||||
|
setTitle,
|
||||||
getExitInfo,
|
getExitInfo,
|
||||||
kill,
|
kill,
|
||||||
killAndWait,
|
killAndWait,
|
||||||
|
|||||||
@@ -256,6 +256,16 @@ export function createWorkerTerminalManager(
|
|||||||
getTitle(): string | undefined {
|
getTitle(): string | undefined {
|
||||||
return record.info.title;
|
return record.info.title;
|
||||||
},
|
},
|
||||||
|
setTitle(nextTitle: string): void {
|
||||||
|
const manualTitle = nextTitle.trim();
|
||||||
|
if (!manualTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
record.info = { ...record.info, title: manualTitle };
|
||||||
|
for (const listener of Array.from(record.titleChangeListeners)) {
|
||||||
|
listener(manualTitle);
|
||||||
|
}
|
||||||
|
},
|
||||||
getExitInfo(): TerminalExitInfo | null {
|
getExitInfo(): TerminalExitInfo | null {
|
||||||
return record.exitInfo;
|
return record.exitInfo;
|
||||||
},
|
},
|
||||||
@@ -536,6 +546,15 @@ export function createWorkerTerminalManager(
|
|||||||
})) as TerminalWorkerStateResult;
|
})) as TerminalWorkerStateResult;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setTerminalTitle(id: string, title: string): boolean {
|
||||||
|
const session = recordsById.get(id)?.session;
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
session.setTitle(title);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
killTerminal(id: string): void {
|
killTerminal(id: string): void {
|
||||||
void sendRequest({ type: "killTerminal", terminalId: id }).catch(() => {
|
void sendRequest({ type: "killTerminal", terminalId: id }).catch(() => {
|
||||||
// no-op; kill is intentionally best-effort and synchronous in the public interface.
|
// no-op; kill is intentionally best-effort and synchronous in the public interface.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { slugify, validateBranchSlug } from "./branch-slug.js";
|
||||||
|
|
||||||
|
describe("branch slug utilities", () => {
|
||||||
|
it("normalizes display names to lowercase branch slugs", () => {
|
||||||
|
expect(slugify("My Feature")).toBe("my-feature");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses punctuation and whitespace to a single hyphen", () => {
|
||||||
|
expect(slugify("My___Feature! @#$ Next")).toBe("my-feature-next");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims leading and trailing hyphens", () => {
|
||||||
|
expect(slugify(" --- My Feature !!! ")).toBe("my-feature");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the 50 character slug limit", () => {
|
||||||
|
const slug = slugify("a".repeat(60));
|
||||||
|
|
||||||
|
expect(slug).toBe("a".repeat(50));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates branch slugs with clear messages", () => {
|
||||||
|
expect(validateBranchSlug("my-feature")).toEqual({ valid: true });
|
||||||
|
expect(validateBranchSlug("")).toEqual({
|
||||||
|
valid: false,
|
||||||
|
error: "Branch name cannot be empty",
|
||||||
|
});
|
||||||
|
expect(validateBranchSlug("My Feature")).toEqual({
|
||||||
|
valid: false,
|
||||||
|
error:
|
||||||
|
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
||||||
|
});
|
||||||
|
expect(validateBranchSlug("-my-feature")).toEqual({
|
||||||
|
valid: false,
|
||||||
|
error: "Branch name cannot start or end with a hyphen",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is exported through the package subpath", () => {
|
||||||
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
readFileSync(join(currentDir, "..", "..", "package.json"), "utf8"),
|
||||||
|
) as {
|
||||||
|
exports?: Record<string, { types?: string; source?: string; default?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(packageJson.exports?.["./utils/branch-slug"]).toEqual({
|
||||||
|
types: "./dist/server/utils/branch-slug.d.ts",
|
||||||
|
source: "./src/utils/branch-slug.ts",
|
||||||
|
default: "./dist/server/utils/branch-slug.js",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not import server-only modules", () => {
|
||||||
|
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const source = readFileSync(join(currentDir, "branch-slug.ts"), "utf8");
|
||||||
|
|
||||||
|
expect(source).not.toMatch(
|
||||||
|
/from\s+["'](?:node:)?(?:fs|path|child_process)["']|from\s+["']node:/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Validate that a string is a valid git branch name slug.
|
||||||
|
* Must be lowercase alphanumeric with hyphens and forward slashes only.
|
||||||
|
*/
|
||||||
|
export function validateBranchSlug(slug: string): {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (!slug || slug.length === 0) {
|
||||||
|
return { valid: false, error: "Branch name cannot be empty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug.length > 100) {
|
||||||
|
return { valid: false, error: "Branch name too long (max 100 characters)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPattern = /^[a-z0-9-/]+$/;
|
||||||
|
if (!validPattern.test(slug)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error:
|
||||||
|
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug.startsWith("-") || slug.endsWith("-")) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Branch name cannot start or end with a hyphen",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug.includes("--")) {
|
||||||
|
return { valid: false, error: "Branch name cannot have consecutive hyphens" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_SLUG_LENGTH = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a string to kebab-case for branch names.
|
||||||
|
*/
|
||||||
|
export function slugify(input: string): string {
|
||||||
|
const slug = input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
|
if (slug.length <= MAX_SLUG_LENGTH) {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = slug.slice(0, MAX_SLUG_LENGTH);
|
||||||
|
const lastHyphen = truncated.lastIndexOf("-");
|
||||||
|
if (lastHyphen > MAX_SLUG_LENGTH / 2) {
|
||||||
|
return truncated.slice(0, lastHyphen);
|
||||||
|
}
|
||||||
|
return truncated.replace(/-+$/, "");
|
||||||
|
}
|
||||||
@@ -16,8 +16,12 @@ vi.mock("child_process", async () => {
|
|||||||
const [command, commandArgs] = args;
|
const [command, commandArgs] = args;
|
||||||
if (command === "git" && Array.isArray(commandArgs)) {
|
if (command === "git" && Array.isArray(commandArgs)) {
|
||||||
const normalizedArgs = commandArgs.map((arg) => String(arg));
|
const normalizedArgs = commandArgs.map((arg) => String(arg));
|
||||||
|
// `runGitCommand` always prepends `-c core.quotepath=false`; skip it to
|
||||||
|
// find the actual git subcommand.
|
||||||
|
const subcommandIndex =
|
||||||
|
normalizedArgs[0] === "-c" && normalizedArgs[1] === "core.quotepath=false" ? 2 : 0;
|
||||||
const isTrackedTextDiff =
|
const isTrackedTextDiff =
|
||||||
normalizedArgs[0] === "diff" &&
|
normalizedArgs[subcommandIndex] === "diff" &&
|
||||||
normalizedArgs.includes("HEAD") &&
|
normalizedArgs.includes("HEAD") &&
|
||||||
!normalizedArgs.includes("--numstat") &&
|
!normalizedArgs.includes("--numstat") &&
|
||||||
!normalizedArgs.includes("--no-index") &&
|
!normalizedArgs.includes("--no-index") &&
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { execFileSync } from "child_process";
|
import { execFileSync, execSync } from "child_process";
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
resolveBranchCheckout,
|
resolveBranchCheckout,
|
||||||
resolveRepositoryDefaultBranch,
|
resolveRepositoryDefaultBranch,
|
||||||
parseWorktreeList,
|
parseWorktreeList,
|
||||||
|
renameCurrentBranch,
|
||||||
isPaseoWorktreePath,
|
isPaseoWorktreePath,
|
||||||
isDescendantPath,
|
isDescendantPath,
|
||||||
warmCheckoutShortstatInBackground,
|
warmCheckoutShortstatInBackground,
|
||||||
@@ -263,6 +264,48 @@ describe("checkout git utilities", () => {
|
|||||||
expect(branch).toBe("feature/rebase-test");
|
expect(branch).toBe("feature/rebase-test");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renames the checked out branch and returns concrete branch names", async () => {
|
||||||
|
execSync("git checkout -b feature/old-name", { cwd: repoDir });
|
||||||
|
|
||||||
|
const result = await renameCurrentBranch(repoDir, "feature/new-name");
|
||||||
|
|
||||||
|
const currentBranch = execSync("git branch --show-current", { cwd: repoDir }).toString().trim();
|
||||||
|
expect(currentBranch).toBe("feature/new-name");
|
||||||
|
expect(result).toEqual({
|
||||||
|
previousBranch: "feature/old-name",
|
||||||
|
currentBranch: "feature/new-name",
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }),
|
||||||
|
).toThrow();
|
||||||
|
expect(
|
||||||
|
execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir })
|
||||||
|
.toString()
|
||||||
|
.trim(),
|
||||||
|
).toContain("refs/heads/feature/new-name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when renaming the checked out branch to an existing branch", async () => {
|
||||||
|
execSync("git branch feature/new-name", { cwd: repoDir });
|
||||||
|
execSync("git checkout -b feature/old-name", { cwd: repoDir });
|
||||||
|
|
||||||
|
await expect(renameCurrentBranch(repoDir, "feature/new-name")).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(execSync("git branch --show-current", { cwd: repoDir }).toString().trim()).toBe(
|
||||||
|
"feature/old-name",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir })
|
||||||
|
.toString()
|
||||||
|
.trim(),
|
||||||
|
).toContain("refs/heads/feature/old-name");
|
||||||
|
expect(
|
||||||
|
execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir })
|
||||||
|
.toString()
|
||||||
|
.trim(),
|
||||||
|
).toContain("refs/heads/feature/new-name");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles status/diff/commit in a normal repo", async () => {
|
it("handles status/diff/commit in a normal repo", async () => {
|
||||||
writeFileSync(join(repoDir, "file.txt"), "updated\n");
|
writeFileSync(join(repoDir, "file.txt"), "updated\n");
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export type WorkspaceMatchMode = "fuzzy" | "suffix";
|
|||||||
|
|
||||||
const DEFAULT_LIMIT = 30;
|
const DEFAULT_LIMIT = 30;
|
||||||
const MAX_LIMIT = 100;
|
const MAX_LIMIT = 100;
|
||||||
const DEFAULT_MAX_DEPTH = 6;
|
const DEFAULT_MAX_DEPTH = 12;
|
||||||
const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000;
|
const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000;
|
||||||
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
|
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
|
||||||
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
|
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export function runGitCommand(
|
|||||||
logger.trace(traceContext, "Spawning git command");
|
logger.trace(traceContext, "Spawning git command");
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = spawnProcess("git", args, {
|
// `core.quotepath=false` makes git emit raw UTF-8 paths instead of
|
||||||
|
// octal-escaping non-ASCII bytes (e.g. `测试文件.txt` vs `"\346\265\213..."`).
|
||||||
|
const child = spawnProcess("git", ["-c", "core.quotepath=false", ...args], {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
envOverlay,
|
envOverlay,
|
||||||
shell: false,
|
shell: false,
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import { spawnProcess } from "./spawn.js";
|
|||||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||||
|
import { validateBranchSlug } from "./branch-slug.js";
|
||||||
|
|
||||||
|
export { slugify, validateBranchSlug } from "./branch-slug.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const READ_ONLY_GIT_ENV = {
|
const READ_ONLY_GIT_ENV = {
|
||||||
@@ -740,72 +743,6 @@ export async function getGitCommonDir(cwd: string): Promise<string> {
|
|||||||
return commonDir;
|
return commonDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a string is a valid git branch name slug
|
|
||||||
* Must be lowercase, alphanumeric, hyphens only
|
|
||||||
*/
|
|
||||||
export function validateBranchSlug(slug: string): {
|
|
||||||
valid: boolean;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
if (!slug || slug.length === 0) {
|
|
||||||
return { valid: false, error: "Branch name cannot be empty" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slug.length > 100) {
|
|
||||||
return { valid: false, error: "Branch name too long (max 100 characters)" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for valid characters: lowercase letters, numbers, hyphens, forward slashes
|
|
||||||
const validPattern = /^[a-z0-9-/]+$/;
|
|
||||||
if (!validPattern.test(slug)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error:
|
|
||||||
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot start or end with hyphen
|
|
||||||
if (slug.startsWith("-") || slug.endsWith("-")) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Branch name cannot start or end with a hyphen",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot have consecutive hyphens
|
|
||||||
if (slug.includes("--")) {
|
|
||||||
return { valid: false, error: "Branch name cannot have consecutive hyphens" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_SLUG_LENGTH = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert string to kebab-case for branch names
|
|
||||||
*/
|
|
||||||
export function slugify(input: string): string {
|
|
||||||
const slug = input
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "");
|
|
||||||
|
|
||||||
if (slug.length <= MAX_SLUG_LENGTH) {
|
|
||||||
return slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate at word boundary (hyphen) if possible
|
|
||||||
const truncated = slug.slice(0, MAX_SLUG_LENGTH);
|
|
||||||
const lastHyphen = truncated.lastIndexOf("-");
|
|
||||||
if (lastHyphen > MAX_SLUG_LENGTH / 2) {
|
|
||||||
return truncated.slice(0, lastHyphen);
|
|
||||||
}
|
|
||||||
return truncated.replace(/-+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKTREE_PROJECT_HASH_LENGTH = 8;
|
const WORKTREE_PROJECT_HASH_LENGTH = 8;
|
||||||
|
|
||||||
function deriveShortAlphanumericHash(value: string): string {
|
function deriveShortAlphanumericHash(value: string): string {
|
||||||
|
|||||||
+18
-19
@@ -15,20 +15,19 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may
|
|||||||
|
|
||||||
### Agents
|
### Agents
|
||||||
|
|
||||||
| Tool | Function |
|
| Tool | Function |
|
||||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||||
| `create_agent` | Create an agent tied to a working directory, optionally with an initial prompt, provider features, or a new git worktree. |
|
| `create_agent` | Create an agent tied to a working directory, optionally with initial settings or a new git worktree. |
|
||||||
| `wait_for_agent` | Block until an agent requests permission or finishes its current run. |
|
| `wait_for_agent` | Block until an agent requests permission or finishes its current run. |
|
||||||
| `send_agent_prompt` | Send a task to a running agent. |
|
| `send_agent_prompt` | Send a task to a running agent. |
|
||||||
| `get_agent_status` | Return the latest snapshot for an agent. |
|
| `get_agent_status` | Return the latest snapshot for an agent. |
|
||||||
| `list_agents` | List recent agents as compact metadata. |
|
| `list_agents` | List recent agents as compact metadata. |
|
||||||
| `cancel_agent` | Abort an agent's current run but keep the agent alive. |
|
| `cancel_agent` | Abort an agent's current run but keep the agent alive. |
|
||||||
| `archive_agent` | Soft-delete an agent and remove it from the active list. |
|
| `archive_agent` | Soft-delete an agent and remove it from the active list. |
|
||||||
| `kill_agent` | Terminate an agent session permanently. |
|
| `kill_agent` | Terminate an agent session permanently. |
|
||||||
| `update_agent` | Update an agent name or labels. |
|
| `update_agent` | Update an agent name, labels, or runtime settings such as mode/model/thinking/features. |
|
||||||
| `get_agent_activity` | Return recent agent timeline entries as a curated summary. |
|
| `get_agent_activity` | Return recent agent timeline entries as a curated summary. |
|
||||||
| `set_agent_mode` | Switch an agent's session mode. |
|
| `set_agent_mode` | Switch an agent's session mode. |
|
||||||
| `set_agent_feature` | Set a provider-specific feature on an existing agent, for example Codex `fast_mode`. |
|
|
||||||
|
|
||||||
### Terminals
|
### Terminals
|
||||||
|
|
||||||
@@ -53,11 +52,11 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may
|
|||||||
|
|
||||||
### Providers
|
### Providers
|
||||||
|
|
||||||
| Tool | Function |
|
| Tool | Function |
|
||||||
| ------------------------ | ------------------------------------------------------------------------------------------- |
|
| ------------------ | ----------------------------------------------------------------- |
|
||||||
| `list_providers` | List configured agent providers, availability, and modes. |
|
| `list_providers` | List configured agent providers, availability, and modes. |
|
||||||
| `list_models` | List models for an agent provider. |
|
| `list_models` | List models for an agent provider. |
|
||||||
| `list_provider_features` | List provider-specific features for a draft agent configuration, such as Codex `fast_mode`. |
|
| `inspect_provider` | Inspect compact provider capabilities and draft feature settings. |
|
||||||
|
|
||||||
### Worktrees
|
### Worktrees
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -20,25 +20,29 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo.
|
|||||||
|
|
||||||
## Agents
|
## Agents
|
||||||
|
|
||||||
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `features`. Returns `{ agentId, … }`.
|
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `settings`. Returns `{ agentId, … }`.
|
||||||
|
|
||||||
Provider features are provider-specific. For Codex fast mode, pass `features: { "fast_mode": true }` when creating the agent.
|
Initial runtime settings live under `settings`: `modeId`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }` when creating the agent.
|
||||||
|
|
||||||
Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`.
|
Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`.
|
||||||
|
|
||||||
**`send_agent_prompt`** — `{ agentId, prompt }`. Blocks by default; pass `background: true` to fire-and-forget.
|
**`send_agent_prompt`** — `{ agentId, prompt }`. Blocks by default; pass `background: true` to fire-and-forget.
|
||||||
|
|
||||||
**`set_agent_feature`** — `{ agentId, featureId, value }`. Use for provider-specific toggles on an existing agent, for example `{ agentId, featureId: "fast_mode", value: true }` for Codex.
|
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.
|
||||||
|
|
||||||
**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`.
|
**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`.
|
||||||
|
|
||||||
**`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list.
|
**`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list.
|
||||||
|
|
||||||
## Provider features
|
## Provider discovery
|
||||||
|
|
||||||
**`list_provider_features`** — query provider-specific features before setting them. Required: `provider`, `cwd`. Optional: `model`, `modeId`, `thinkingOptionId`, `featureValues`.
|
**`list_providers`** — compact provider availability and modes.
|
||||||
|
|
||||||
Only set feature IDs returned by `list_provider_features`. For Codex fast mode, look for `fast_mode` and pass `features: { "fast_mode": true }` to `create_agent`.
|
**`list_models`** — full model list for one provider. Use only when you need model IDs or thinking options; the list can be large.
|
||||||
|
|
||||||
|
**`inspect_provider`** — compact provider capability and feature inspection. Required: `provider`; pass `cwd` when you are not in an agent-scoped session. Optional: `settings` with draft `model`, `modeId`, `thinkingOptionId`, and `features`.
|
||||||
|
|
||||||
|
Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look for `fast_mode` and pass `settings: { features: { "fast_mode": true } }` to `create_agent` or `update_agent`.
|
||||||
|
|
||||||
## Heartbeats
|
## Heartbeats
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user