mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6983ca322d | ||
|
|
555d10f046 | ||
|
|
a1a5119bc5 | ||
|
|
4cf985c254 | ||
|
|
0e1c590a3b | ||
|
|
b7a8567092 | ||
|
|
ad9b149bf7 | ||
|
|
306601a0c3 | ||
|
|
da0e94fd18 | ||
|
|
84fb7e9e06 | ||
|
|
5dd6b030f2 | ||
|
|
bc7798af28 | ||
|
|
f238cbc20c | ||
|
|
41c5eea678 | ||
|
|
352e8fb6eb | ||
|
|
8d65f8bc96 | ||
|
|
2c561536e4 | ||
|
|
3743df09e6 | ||
|
|
0a2307d199 | ||
|
|
0f6641c8c0 | ||
|
|
fdecd75f94 | ||
|
|
b41cb72da0 | ||
|
|
cb96485035 | ||
|
|
ce822f989f | ||
|
|
e38d0e0fa9 | ||
|
|
bc32b16b02 | ||
|
|
b2cdbdaed8 | ||
|
|
9ef7230417 | ||
|
|
383b380d8a | ||
|
|
339ca2fc83 | ||
|
|
8ff63a6d71 | ||
|
|
aaabadb04b | ||
|
|
501dcf373b | ||
|
|
5a1c7f266c | ||
|
|
9bc6210823 | ||
|
|
8aa8f7a0cc |
@@ -339,6 +339,8 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including Google Antigravity CLI, Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
Set `extends: "acp"` and provide a `command`:
|
||||
|
||||
@@ -139,6 +139,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ Anyone who builds software:
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
|
||||
+9
-1
@@ -14,7 +14,15 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
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` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
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`
|
||||
- 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`
|
||||
|
||||
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
|
||||
- Never re-run a suite another agent already reported green.
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run Playwright E2E (`packages/app/e2e/*.spec.ts`) locally — defer to CI.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-zuTLTDFUzpnB7W+hS6Cl8UClDSKAnGArmu757S9CJww=
|
||||
sha256-vNhmXjWV1wf17JHVX6tpTz4CKeCE7hpyBLj8nzPVUH0=
|
||||
|
||||
Generated
+25
-2820
File diff suppressed because it is too large
Load Diff
@@ -188,7 +188,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let metroProcess: ChildProcess | null = null;
|
||||
let paseoHome: string | null = null;
|
||||
let fakeGhBinDir: string | null = null;
|
||||
let fakeToolBinDir: string | null = null;
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
|
||||
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
||||
@@ -209,8 +209,8 @@ interface OfferPayload {
|
||||
relay: { endpoint: string };
|
||||
}
|
||||
|
||||
async function createFakeGhBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
|
||||
async function createFakeToolBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
await writeFile(
|
||||
ghPath,
|
||||
@@ -284,6 +284,27 @@ forwardToRealGh();
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH;
|
||||
|
||||
if (recordPath) {
|
||||
fs.appendFileSync(recordPath, JSON.stringify({
|
||||
command: path.basename(process.argv[1]),
|
||||
args: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
at: Date.now()
|
||||
}) + "\\n");
|
||||
}
|
||||
`;
|
||||
for (const editorCommand of ["cursor", "code"]) {
|
||||
const editorPath = path.join(binDir, editorCommand);
|
||||
await writeFile(editorPath, fakeEditorSource);
|
||||
await chmod(editorPath, 0o755);
|
||||
}
|
||||
|
||||
return binDir;
|
||||
}
|
||||
|
||||
@@ -599,7 +620,8 @@ interface DaemonSpawnArgs {
|
||||
relayPort: number;
|
||||
metroPort: number;
|
||||
paseoHome: string;
|
||||
fakeGhBinDir: string;
|
||||
fakeToolBinDir: string;
|
||||
editorRecordPath: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
}
|
||||
@@ -613,8 +635,9 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
@@ -678,9 +701,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
} else if (paseoHome) {
|
||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||
}
|
||||
if (fakeGhBinDir) {
|
||||
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||
fakeGhBinDir = null;
|
||||
if (fakeToolBinDir) {
|
||||
await rm(fakeToolBinDir, { recursive: true, force: true });
|
||||
fakeToolBinDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -694,7 +717,8 @@ export default async function globalSetup() {
|
||||
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
|
||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||
fakeGhBinDir = await createFakeGhBin();
|
||||
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
|
||||
fakeToolBinDir = await createFakeToolBin();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
@@ -712,7 +736,8 @@ export default async function globalSetup() {
|
||||
relayPort,
|
||||
metroPort,
|
||||
paseoHome,
|
||||
fakeGhBinDir,
|
||||
fakeToolBinDir,
|
||||
editorRecordPath,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
@@ -742,6 +767,7 @@ export default async function globalSetup() {
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
process.env.E2E_PASEO_HOME = paseoHome;
|
||||
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
@@ -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> {
|
||||
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> {
|
||||
@@ -176,9 +176,9 @@ export async function clickEditHostLabel(page: Page): 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-label-input")).toHaveValue(expectedLabel);
|
||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel);
|
||||
await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible();
|
||||
}
|
||||
|
||||
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,113 @@
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
|
||||
interface EditorOpenRecord {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function requireE2EEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readEditorOpenRecords(recordPath: string): Promise<EditorOpenRecord[]> {
|
||||
try {
|
||||
const contents = await readFile(recordPath, "utf8");
|
||||
return contents
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line) as EditorOpenRecord);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-open-in-editor-caret").click();
|
||||
await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible();
|
||||
await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click();
|
||||
}
|
||||
|
||||
async function expectEditorOpened(input: {
|
||||
recordPath: string;
|
||||
command: string;
|
||||
workspacePath: string;
|
||||
afterCount: number;
|
||||
}): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const records = await readEditorOpenRecords(input.recordPath);
|
||||
return records
|
||||
.slice(input.afterCount)
|
||||
.some(
|
||||
(record) =>
|
||||
record.command === input.command && record.args.includes(input.workspacePath),
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.describe("Workspace open in editor", () => {
|
||||
test("keeps the selected editor target after leaving and returning to the workspace", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
||||
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
||||
await rm(recordPath, { force: true });
|
||||
await injectDesktopBridge(page, { serverId });
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await chooseEditorTarget(page, "vscode");
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
afterCount: 0,
|
||||
});
|
||||
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
|
||||
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
afterCount: recordsAfterSelection,
|
||||
});
|
||||
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
|
||||
|
||||
await gotoAppShell(page);
|
||||
await workspace.navigateTo();
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
afterCount: recordsAfterReturnOpen,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -171,14 +171,24 @@ describe("resolveStartupRedirectRoute", () => {
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("redirects to the host root when the persisted workspace targets a different server", () => {
|
||||
it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/server-2");
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the persisted workspace when another host is first online", () => {
|
||||
const selection = resolveStartupWorkspaceSelection({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("redirects to the host root when no persisted workspace exists", () => {
|
||||
|
||||
@@ -79,11 +79,7 @@ export function resolveStartupWorkspaceSelection(
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!input.anyOnlineHostServerId ||
|
||||
!input.workspaceSelection ||
|
||||
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
|
||||
) {
|
||||
if (!input.workspaceSelection) {
|
||||
return null;
|
||||
}
|
||||
return input.workspaceSelection;
|
||||
|
||||
@@ -18,6 +18,7 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
|
||||
storeReady: false,
|
||||
} as HostRuntimeBootstrapState,
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||
};
|
||||
|
||||
@@ -51,7 +52,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: navigateToWorkspaceMock,
|
||||
useActiveWorkspaceSelection: () => state.workspaceSelection,
|
||||
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
|
||||
useLastWorkspaceSelection: () => state.workspaceSelection,
|
||||
}));
|
||||
|
||||
describe("Index route startup navigation", () => {
|
||||
@@ -68,6 +70,7 @@ describe("Index route startup navigation", () => {
|
||||
storeReady: false,
|
||||
};
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.isWorkspaceSelectionLoaded = true;
|
||||
state.workspaceSelection = null;
|
||||
navigateToWorkspaceMock.mockReset();
|
||||
redirectMock.mockReset();
|
||||
@@ -98,6 +101,17 @@ describe("Index route startup navigation", () => {
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.isWorkspaceSelectionLoaded = false;
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(navigateToWorkspaceMock).not.toHaveBeenCalled();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace when the online host matches its server id", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
@@ -111,13 +125,16 @@ describe("Index route startup navigation", () => {
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("navigates to the host root when the persisted workspace targets a different server", async () => {
|
||||
it("restores the persisted workspace even when the first online host is different", async () => {
|
||||
state.anyOnlineHostServerId = "server-2";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
|
||||
currentPathname: "/",
|
||||
});
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates to the host root when no persisted workspace exists", async () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
navigateToWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
@@ -18,20 +19,21 @@ export default function Index() {
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const workspaceSelection = useActiveWorkspaceSelection();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
|
||||
const redirectRoute = resolveStartupRedirectRoute({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">\n <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>\n <path d="M5.5 10.5L8 4L10.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>\n <line x1="6.25" y1="8.75" x2="9.75" y2="8.75" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>\n</svg>\n',
|
||||
"amp-acp":
|
||||
'<svg width="16" height="16" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="currentColor"/>\n<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="currentColor"/>\n<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="currentColor"/>\n<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="currentColor"/>\n</svg>\n',
|
||||
antigravity:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">\n <path d="M8 1.5L13.75 14.5H11.45L10.2 11.45H5.75L4.55 14.5H2.25L8 1.5ZM6.5 9.55H9.45L8 5.95L6.5 9.55Z" fill="currentColor"/>\n <path d="M2.25 7.9C2.25 5.1 4.55 2.85 7.4 2.85C10.75 2.85 13.35 5.65 13.35 8.9C13.35 11.4 11.35 13.15 9.1 13.15" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>\n</svg>\n',
|
||||
auggie:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">\n <path fill="currentColor" d="M9.972 13.193h2.577q.187 0 .277-.09t.091-.294V10.47q0-.324.133-.59.135-.27.36-.424a1 1 0 0 1 .099-.06 1 1 0 0 1-.1-.058 1.1 1.1 0 0 1-.359-.424 1.3 1.3 0 0 1-.133-.59v-2.33q0-.202-.091-.293t-.277-.089H9.972a.38.38 0 0 1-.294-.124.4.4 0 0 1-.108-.281q.002-.176.131-.293a.46.46 0 0 1 .313-.111h2.775c.36 0 .642.097.836.288q.293.29.294.832V8.24q0 .39.152.562.148.168.555.178a.4.4 0 0 1 .27.128.41.41 0 0 1 .104.287.44.44 0 0 1-.1.282.34.34 0 0 1-.277.132c-.266.006-.452.066-.55.177q-.151.174-.152.573v2.318c0 .235-.043.44-.128.607a.85.85 0 0 1-.389.387 1.3 1.3 0 0 1-.534.124V14h-2.854a.45.45 0 0 1-.315-.118.38.38 0 0 1-.129-.286q0-.164.109-.282a.38.38 0 0 1 .293-.123zM1.103 9.108a.4.4 0 0 1 .276-.128q.4-.008.55-.177.15-.173.151-.562V5.923q.002-.543.294-.832c.195-.191.477-.288.836-.288h2.775q.188 0 .313.111a.37.37 0 0 1 .131.293.4.4 0 0 1-.108.281.38.38 0 0 1-.293.123H3.45q-.186 0-.277.09t-.092.292v2.33c0 .213-.044.413-.133.59a1.1 1.1 0 0 1-.359.424 1 1 0 0 1-.1.059 1 1 0 0 1 .1.059q.225.156.359.423t.133.59v2.34q0 .203.092.293.088.09.277.089h2.577a.38.38 0 0 1 .294.123q.108.12.108.281a.38.38 0 0 1-.13.286.45.45 0 0 1-.314.118l-2.775-.003a1.4 1.4 0 0 1-.613-.126.87.87 0 0 1-.388-.387 1.34 1.34 0 0 1-.129-.608v-2.318q0-.4-.151-.572-.151-.168-.55-.177a.35.35 0 0 1-.278-.132.42.42 0 0 1-.102-.28q0-.173.103-.287"/>\n <path fill="currentColor" d="M5.437 10.36a.986.986 0 0 1-.994-.975c0-.538.446-.976.994-.976s.994.438.994.976a.986.986 0 0 1-.994.976M10.562 8.41c.548 0 .994.437.994.975a.986.986 0 0 1-.994.976.986.986 0 0 1-.994-.976c0-.538.446-.976.994-.976M8.74 2c.37 0 .446.15.446.275v.034q0 .034-.009.201-.01.162-.017.549-.018.378-.044 1.06c0 .106-.065.233-.375.233-.311 0-.376-.126-.376-.23l-.07-1.835v-.003c0-.13.078-.284.446-.284M7.256 2c.369 0 .446.15.446.275v.034q0 .034-.01.201-.007.162-.017.549-.017.378-.043 1.06c0 .106-.065.233-.376.233-.31 0-.375-.126-.375-.23l-.07-1.835v-.003C6.81 2.154 6.887 2 7.256 2"/>\n</svg>\n',
|
||||
autohand:
|
||||
@@ -27,6 +29,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n',
|
||||
deepagents:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">\n <path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>\n <path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>\n <path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>\n <path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>\n</svg>\n',
|
||||
"deepseek-tui":
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">\n <rect width="32" height="32" fill="currentColor" opacity="0.18"/>\n <text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="\'Noto Serif SC\', serif" font-weight="700" font-size="20" fill="currentColor">深</text>\n <rect x="0" y="29" width="32" height="3" fill="currentColor"/>\n</svg>\n',
|
||||
dimcode:
|
||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>\n</svg>\n',
|
||||
dirac:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="currentColor" opacity="0.18"/>
|
||||
<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-family="'Noto Serif SC', serif" font-weight="700" font-size="20" fill="currentColor">深</text>
|
||||
<rect x="0" y="29" width="32" height="3" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 397 B |
@@ -1,6 +1,5 @@
|
||||
export {
|
||||
AssistantInlineCodePathLink,
|
||||
AssistantInlinePathLink,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
} from "./link";
|
||||
@@ -9,5 +8,9 @@ export {
|
||||
normalizeInlinePathTarget,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
export {
|
||||
AssistantFileLinkResolverProvider,
|
||||
type AssistantFileLinkResolverProviderProps,
|
||||
} from "./provider";
|
||||
export type { AssistantFileLinkSource } from "./resolver";
|
||||
export { useAssistantFileLinkResolver } from "./use-resolver";
|
||||
export { useAssistantFileLinkActions } from "./use-file-link";
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
@@ -16,128 +9,66 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
style: StyleProp<TextStyle>;
|
||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
workspaceRoot?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AssistantMarkdownLink({
|
||||
source,
|
||||
style,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
workspaceRoot,
|
||||
children,
|
||||
}: AssistantMarkdownLinkProps) {
|
||||
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const href = source.href;
|
||||
const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]);
|
||||
const handleAnchorClickCapture = useCallback(
|
||||
(event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isModifiedOpenEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onPress(source, "side");
|
||||
},
|
||||
[onPress, source],
|
||||
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
|
||||
const handleHoverIn = useCallback(() => {
|
||||
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isModifiedOpenEvent(event)) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
onAuxPress();
|
||||
});
|
||||
const handleHoverIn = useStableEvent(() => {
|
||||
setHovered(true);
|
||||
handlePrefetch();
|
||||
}, [handlePrefetch]);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
onHoverIn();
|
||||
});
|
||||
const handleHoverOut = useStableEvent(() => setHovered(false));
|
||||
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||
[style, hovered],
|
||||
);
|
||||
const tooltipFilePath = useMemo(
|
||||
() => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot),
|
||||
[source.href, workspaceRoot],
|
||||
);
|
||||
|
||||
if (isNative) {
|
||||
return (
|
||||
<Text accessibilityRole="link" onPress={handlePress} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||
<Text accessibilityRole="link" onPress={onPress} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<a
|
||||
href={href}
|
||||
href={source.href}
|
||||
onClickCapture={handleAnchorClickCapture}
|
||||
onAuxClickCapture={preventAnchorNavigation}
|
||||
style={LINK_ANCHOR_STYLE}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
onFocus={handlePrefetch}
|
||||
onPress={onPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
@@ -146,10 +77,7 @@ export function AssistantMarkdownLink({
|
||||
</a>
|
||||
);
|
||||
|
||||
if (tooltipFilePath) {
|
||||
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
|
||||
}
|
||||
return anchor;
|
||||
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
|
||||
}
|
||||
|
||||
interface AssistantMarkdownCodeLinkProps {
|
||||
@@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps {
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
workspaceRoot?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
workspaceRoot,
|
||||
children,
|
||||
}: AssistantMarkdownCodeLinkProps) {
|
||||
const style = useMemo(
|
||||
@@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({
|
||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||
);
|
||||
return (
|
||||
<AssistantMarkdownLink
|
||||
source={source}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
onPrefetch={onPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
<AssistantMarkdownLink source={source} style={style}>
|
||||
{children}
|
||||
</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(
|
||||
target: InlinePathTarget,
|
||||
target: { path: string; lineStart?: number; lineEnd?: number },
|
||||
workspaceRoot: string | undefined,
|
||||
): string {
|
||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||
@@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und
|
||||
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 = {
|
||||
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
|
||||
// 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"];
|
||||
|
||||
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) {
|
||||
function FileLinkHoverTooltip({
|
||||
filePath,
|
||||
children,
|
||||
}: {
|
||||
filePath: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!isWeb) {
|
||||
return children;
|
||||
}
|
||||
@@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr
|
||||
<TooltipTrigger asChild>
|
||||
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||
<View style={styles.tooltipBody}>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{filePath}
|
||||
</Text>
|
||||
<View style={styles.tooltipHintRow}>
|
||||
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
|
||||
<Text selectable={false} style={styles.tooltipHintText}>
|
||||
click for side pane
|
||||
{filePath ? (
|
||||
<TooltipContent side="top" align="start" maxWidth={520}>
|
||||
<View style={styles.tooltipBody}>
|
||||
<Text selectable={false} style={styles.tooltipPath}>
|
||||
{filePath}
|
||||
</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>
|
||||
</TooltipContent>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</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 {
|
||||
createAssistantFileLinkResolver,
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
getAssistantFileLinkToken,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionEntry,
|
||||
type DirectorySuggestionResult,
|
||||
} from "./resolver";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import type { InlinePathTarget } from "./parse";
|
||||
|
||||
const CONTEXT: AssistantFileLinkContext = {
|
||||
serverId: "server-1",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
@@ -20,554 +19,201 @@ function resolvedSuggestions(
|
||||
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 };
|
||||
function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
|
||||
getDirectorySuggestions: ReturnType<typeof vi.fn>;
|
||||
searches: Array<{
|
||||
query: string;
|
||||
cwd: string;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}>;
|
||||
} {
|
||||
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 {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}
|
||||
describe("classifyForResolution", () => {
|
||||
it("returns the directFile target synchronously", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||
|
||||
interface OpenedFile {
|
||||
target: InlinePathTarget;
|
||||
disposition: OpenFileDisposition;
|
||||
}
|
||||
|
||||
class FakeWorkspaceFiles {
|
||||
readonly searches: DirectorySearch[] = [];
|
||||
readonly openedFiles: OpenedFile[] = [];
|
||||
readonly unresolvedTokens: string[] = [];
|
||||
|
||||
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
|
||||
|
||||
createResolver() {
|
||||
return createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: this.getDirectorySuggestions,
|
||||
openWorkspaceFile: this.openWorkspaceFile,
|
||||
openExternalUrl: async () => {},
|
||||
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
private getDirectorySuggestions = async (
|
||||
search: DirectorySearch,
|
||||
): Promise<DirectorySuggestionResult> => {
|
||||
this.searches.push(search);
|
||||
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
|
||||
};
|
||||
it("preserves line ranges on direct workspace files", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT);
|
||||
|
||||
private openWorkspaceFile = (
|
||||
target: InlinePathTarget,
|
||||
disposition: OpenFileDisposition,
|
||||
): void => {
|
||||
this.openedFiles.push({ target, disposition });
|
||||
};
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
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 => {
|
||||
this.unresolvedTokens.push(token);
|
||||
};
|
||||
}
|
||||
|
||||
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" }]),
|
||||
it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => {
|
||||
const result = classifyForResolution(
|
||||
{ href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" },
|
||||
CONTEXT,
|
||||
);
|
||||
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([
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
]);
|
||||
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||
expect(suggestions).toHaveBeenCalledWith({
|
||||
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,
|
||||
expect(result).toEqual({
|
||||
kind: "needsLookup",
|
||||
ambiguousQuery: "file.ts",
|
||||
token: "file.ts:12",
|
||||
target: {
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/file.ts",
|
||||
lineStart: 12,
|
||||
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 () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
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" };
|
||||
it("returns ignored for non-file-looking content", () => {
|
||||
const result = classifyForResolution({ href: "" }, CONTEXT);
|
||||
|
||||
const prefetch = resolver.prefetch({ context: CONTEXT, source });
|
||||
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);
|
||||
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a click after hover prefetch fails to query suggestions", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.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({
|
||||
describe("fetchDaemonResolution", () => {
|
||||
it("resolves daemon suggestions into workspace file targets", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
|
||||
});
|
||||
const resolver = workspaceFiles.createResolver();
|
||||
|
||||
const result = await resolver.open({
|
||||
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
|
||||
source: {
|
||||
href: "file.ts:12",
|
||||
text: "file.ts:12",
|
||||
sourceType: "inline-code",
|
||||
const result = await fetchDaemonResolution({
|
||||
ambiguousQuery: "file.ts",
|
||||
token: "file.ts:12",
|
||||
target: {
|
||||
raw: "file.ts:12",
|
||||
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",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
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: {
|
||||
raw: "file.ts:12",
|
||||
path: "/Users/test/project/packages/app/src/file.ts",
|
||||
lineStart: 12,
|
||||
raw: "src/file.ts",
|
||||
path: "/Users/test/project/src/file.ts",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
disposition: "main",
|
||||
},
|
||||
]);
|
||||
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");
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
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,
|
||||
});
|
||||
).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts"));
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
|
||||
@@ -4,12 +4,6 @@ import {
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
|
||||
export interface AssistantFileLinkContext {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -19,6 +13,10 @@ export interface AssistantFileLinkSource {
|
||||
sourceType?: "inline-code";
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkContext {
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export interface DirectorySuggestionEntry {
|
||||
path: string;
|
||||
kind: "file" | "directory";
|
||||
@@ -29,152 +27,121 @@ export interface DirectorySuggestionResult {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverDependencies {
|
||||
getDirectorySuggestions: (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => 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 GetDirectorySuggestions = (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
matchMode: "suffix";
|
||||
limit: number;
|
||||
}) => Promise<DirectorySuggestionResult>;
|
||||
|
||||
export type ResolvedAssistantFileLink =
|
||||
| { kind: "external"; url: string }
|
||||
| { kind: "file"; target: InlinePathTarget }
|
||||
| { kind: "ignored" };
|
||||
|
||||
export type AssistantFileLinkResolution =
|
||||
| { kind: "resolved"; value: ResolvedAssistantFileLink }
|
||||
| {
|
||||
kind: "external";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
kind: "file";
|
||||
target: InlinePathTarget;
|
||||
}
|
||||
| {
|
||||
kind: "unresolvedFileCandidate";
|
||||
kind: "needsLookup";
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
kind: "ignored";
|
||||
target: InlinePathTarget;
|
||||
};
|
||||
|
||||
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
|
||||
opened: boolean;
|
||||
};
|
||||
|
||||
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
|
||||
|
||||
interface ParsedAssistantFileLinkInteraction {
|
||||
export interface FetchDaemonResolutionInput {
|
||||
ambiguousQuery: string;
|
||||
token: string;
|
||||
classification: AssistantFileLinkClassification;
|
||||
target: InlinePathTarget;
|
||||
workspaceRoot?: string;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}
|
||||
|
||||
export function createAssistantFileLinkResolver(
|
||||
dependencies: AssistantFileLinkResolverDependencies,
|
||||
): AssistantFileLinkResolver {
|
||||
const cache = new Map<string, CachedAssistantFileLink>();
|
||||
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
|
||||
export class UnresolvedFileLinkError extends Error {
|
||||
constructor(readonly token: string) {
|
||||
super(`No file found for ${token}`);
|
||||
this.name = "UnresolvedFileLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(
|
||||
input: AssistantFileLinkPrefetchInput,
|
||||
): Promise<ResolvedAssistantFileLink> {
|
||||
const parsed = parseInteraction(input);
|
||||
if (!parsed) {
|
||||
return { kind: "ignored" };
|
||||
}
|
||||
export async function fetchDaemonResolution({
|
||||
ambiguousQuery,
|
||||
token,
|
||||
target,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions,
|
||||
}: FetchDaemonResolutionInput): Promise<InlinePathTarget> {
|
||||
const trimmedRoot = workspaceRoot?.trim();
|
||||
if (!trimmedRoot) {
|
||||
throw new UnresolvedFileLinkError(token);
|
||||
}
|
||||
|
||||
if (parsed.classification.kind === "external") {
|
||||
return { kind: "external", url: parsed.classification.raw };
|
||||
}
|
||||
let suggestions: DirectorySuggestionResult;
|
||||
try {
|
||||
suggestions = await getDirectorySuggestions({
|
||||
query: ambiguousQuery,
|
||||
cwd: trimmedRoot,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
});
|
||||
} catch {
|
||||
throw new UnresolvedFileLinkError(token);
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.classification.kind === "directFile" &&
|
||||
!shouldResolveDirectFileThroughSuggestions({
|
||||
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;
|
||||
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
||||
if (!match || suggestions.error) {
|
||||
throw new UnresolvedFileLinkError(token);
|
||||
}
|
||||
|
||||
return {
|
||||
prefetch(input) {
|
||||
return resolve(input);
|
||||
},
|
||||
async open(input) {
|
||||
const resolved = await resolve(input);
|
||||
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
|
||||
return { ...resolved, opened: false };
|
||||
}
|
||||
...target,
|
||||
path: joinWorkspacePath(trimmedRoot, match.path),
|
||||
};
|
||||
}
|
||||
|
||||
if (resolved.kind === "file") {
|
||||
dependencies.openWorkspaceFile(resolved.target, input.disposition);
|
||||
return { ...resolved, opened: true };
|
||||
}
|
||||
export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
): AssistantFileLinkResolution {
|
||||
const token = getAssistantFileLinkToken(source).trim();
|
||||
if (!token) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
|
||||
if (resolved.kind === "external") {
|
||||
await dependencies.openExternalUrl(resolved.url);
|
||||
return { ...resolved, opened: true };
|
||||
}
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
});
|
||||
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") {
|
||||
dependencies.onUnresolvedFileCandidate?.(resolved.token);
|
||||
}
|
||||
const workspaceRoot = context.workspaceRoot?.trim();
|
||||
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;
|
||||
}
|
||||
|
||||
function parseInteraction(
|
||||
input: AssistantFileLinkPrefetchInput,
|
||||
): ParsedAssistantFileLinkInteraction | null {
|
||||
const token = getAssistantFileLinkToken(input.source).trim();
|
||||
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 {
|
||||
export function getAmbiguousSuggestionQuery(
|
||||
target: InlinePathTarget,
|
||||
workspaceRoot: string,
|
||||
): string {
|
||||
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = target.path.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
@@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st
|
||||
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
|
||||
}
|
||||
|
||||
function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
export function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
context: AssistantFileLinkContext;
|
||||
source: AssistantFileLinkSource;
|
||||
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 {
|
||||
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 {
|
||||
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
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],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
adaptiveInputOutline: {
|
||||
outlineColor: theme.colors.accent,
|
||||
},
|
||||
adaptiveInputPlaceholder: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||
@@ -234,10 +240,23 @@ export type AdaptiveTextInputProps = TextInputProps & {
|
||||
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
|
||||
function AdaptiveTextInputInner(props, ref) {
|
||||
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 = {
|
||||
...inputProps,
|
||||
defaultValue: initialValue ?? defaultValue,
|
||||
placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color,
|
||||
style: [styles.adaptiveInputOutline, style],
|
||||
};
|
||||
|
||||
if (isMobile && isNative) {
|
||||
|
||||
@@ -489,7 +489,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<View style={checkboxStyle}>
|
||||
{useTls ? (
|
||||
<View testID="direct-ssl-toggle-checked">
|
||||
<Check size={14} color={theme.colors.palette.white} />
|
||||
<Check size={14} color={theme.colors.accentForeground} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import {
|
||||
buildProviderSelectorProviders,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
@@ -93,8 +97,7 @@ interface ControlledAgentStatusBarProps {
|
||||
disabled?: boolean;
|
||||
isModelLoading?: boolean;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
||||
canSelectModelProvider?: (providerId: string) => boolean;
|
||||
modelSelectorProviders?: ProviderSelectorProvider[];
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||
features?: AgentFeature[];
|
||||
@@ -114,7 +117,7 @@ export interface DraftAgentStatusBarProps {
|
||||
selectedModel: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
isModelLoading: boolean;
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
isAllModelsLoading: boolean;
|
||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
@@ -184,10 +187,6 @@ const MODE_ICONS = {
|
||||
ShieldQuestionMark,
|
||||
} as const;
|
||||
|
||||
function alwaysTrue() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveDisplayModel(
|
||||
isModelLoading: boolean,
|
||||
modelOptions: StatusOption[] | undefined,
|
||||
@@ -235,23 +234,29 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
|
||||
return (options ?? []).map((o) => ({ id: o.id, label: o.label }));
|
||||
}
|
||||
|
||||
function buildFallbackAllProviderModels(
|
||||
function buildFallbackModelSelectorProviders(
|
||||
provider: string,
|
||||
modelOptions: StatusOption[] | undefined,
|
||||
): Map<string, AgentModelDefinition[]> {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
): ProviderSelectorProvider[] {
|
||||
if (!modelOptions || modelOptions.length === 0) {
|
||||
return map;
|
||||
return [];
|
||||
}
|
||||
map.set(
|
||||
provider,
|
||||
modelOptions.map((option) => ({
|
||||
provider: provider,
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
})),
|
||||
);
|
||||
return map;
|
||||
return [
|
||||
{
|
||||
id: provider,
|
||||
label: provider,
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: modelOptions.map((option) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
||||
provider,
|
||||
providerLabel: provider,
|
||||
modelId: option.id,
|
||||
modelLabel: option.label,
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function makeBadgePressableStyle(
|
||||
@@ -459,8 +464,7 @@ function ControlledStatusBar({
|
||||
disabled = false,
|
||||
isModelLoading = false,
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
canSelectModelProvider,
|
||||
modelSelectorProviders,
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavoriteModel,
|
||||
features,
|
||||
@@ -521,13 +525,11 @@ function ControlledStatusBar({
|
||||
() => toComboboxOptions(modeOptions),
|
||||
[modeOptions],
|
||||
);
|
||||
const fallbackAllProviderModels = useMemo(
|
||||
() => buildFallbackAllProviderModels(provider, modelOptions),
|
||||
const fallbackModelSelectorProviders = useMemo(
|
||||
() => buildFallbackModelSelectorProviders(provider, modelOptions),
|
||||
[modelOptions, provider],
|
||||
);
|
||||
const effectiveProviderDefinitions = providerDefinitions;
|
||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? alwaysTrue;
|
||||
const effectiveModelSelectorProviders = modelSelectorProviders ?? fallbackModelSelectorProviders;
|
||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||
() => toComboboxOptions(thinkingOptions),
|
||||
[thinkingOptions],
|
||||
@@ -701,10 +703,8 @@ function ControlledStatusBar({
|
||||
canSelectMode={canSelectMode}
|
||||
canSelectModel={canSelectModel}
|
||||
canSelectThinking={canSelectThinking}
|
||||
canSelectProviderInModelMenu={canSelectProviderInModelMenu}
|
||||
modelSelectorProviders={effectiveModelSelectorProviders}
|
||||
modelDisabled={modelDisabled}
|
||||
effectiveProviderDefinitions={effectiveProviderDefinitions}
|
||||
effectiveAllProviderModels={effectiveAllProviderModels}
|
||||
comboboxProviderOptions={comboboxProviderOptions}
|
||||
comboboxModeOptions={comboboxModeOptions}
|
||||
comboboxThinkingOptions={comboboxThinkingOptions}
|
||||
@@ -751,10 +751,8 @@ function ControlledStatusBar({
|
||||
canSelectMode={canSelectMode}
|
||||
canSelectModel={canSelectModel}
|
||||
canSelectThinking={canSelectThinking}
|
||||
canSelectProviderInModelMenu={canSelectProviderInModelMenu}
|
||||
modelSelectorProviders={effectiveModelSelectorProviders}
|
||||
modelDisabled={modelDisabled}
|
||||
effectiveProviderDefinitions={effectiveProviderDefinitions}
|
||||
effectiveAllProviderModels={effectiveAllProviderModels}
|
||||
comboboxModeOptions={comboboxModeOptions}
|
||||
comboboxThinkingOptions={comboboxThinkingOptions}
|
||||
ModeIconComponent={ModeIconComponent}
|
||||
@@ -799,10 +797,8 @@ interface DesktopStatusBarContentProps {
|
||||
canSelectMode: boolean;
|
||||
canSelectModel: boolean;
|
||||
canSelectThinking: boolean;
|
||||
canSelectProviderInModelMenu: (providerId: string) => boolean;
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
modelDisabled: boolean;
|
||||
effectiveProviderDefinitions: AgentProviderDefinition[];
|
||||
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
comboboxProviderOptions: ComboboxOption[];
|
||||
comboboxModeOptions: ComboboxOption[];
|
||||
comboboxThinkingOptions: ComboboxOption[];
|
||||
@@ -868,10 +864,8 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
|
||||
canSelectMode,
|
||||
canSelectModel,
|
||||
canSelectThinking,
|
||||
canSelectProviderInModelMenu,
|
||||
modelSelectorProviders,
|
||||
modelDisabled,
|
||||
effectiveProviderDefinitions,
|
||||
effectiveAllProviderModels,
|
||||
comboboxProviderOptions,
|
||||
comboboxModeOptions,
|
||||
comboboxThinkingOptions,
|
||||
@@ -942,11 +936,9 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<View>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={effectiveProviderDefinitions}
|
||||
allProviderModels={effectiveAllProviderModels}
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={provider}
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={handleDesktopModelSelect}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
@@ -1069,10 +1061,8 @@ interface SheetStatusBarContentProps {
|
||||
canSelectMode: boolean;
|
||||
canSelectModel: boolean;
|
||||
canSelectThinking: boolean;
|
||||
canSelectProviderInModelMenu: (providerId: string) => boolean;
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
modelDisabled: boolean;
|
||||
effectiveProviderDefinitions: AgentProviderDefinition[];
|
||||
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
comboboxModeOptions: ComboboxOption[];
|
||||
comboboxThinkingOptions: ComboboxOption[];
|
||||
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
|
||||
@@ -1118,10 +1108,8 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
|
||||
canSelectMode,
|
||||
canSelectModel,
|
||||
canSelectThinking,
|
||||
canSelectProviderInModelMenu,
|
||||
modelSelectorProviders,
|
||||
modelDisabled,
|
||||
effectiveProviderDefinitions,
|
||||
effectiveAllProviderModels,
|
||||
comboboxModeOptions,
|
||||
comboboxThinkingOptions,
|
||||
ModeIconComponent,
|
||||
@@ -1214,11 +1202,9 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
|
||||
<>
|
||||
{canSelectModel ? (
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={effectiveProviderDefinitions}
|
||||
allProviderModels={effectiveAllProviderModels}
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={provider}
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={handleSheetModelSelect}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
@@ -1678,6 +1664,14 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
() => buildAgentProviderModels(agent?.provider, models),
|
||||
[agent?.provider, models],
|
||||
);
|
||||
const agentModelSelectorProviders = useMemo(
|
||||
() =>
|
||||
buildProviderSelectorProviders({
|
||||
providerDefinitions: agentProviderDefinitions,
|
||||
modelsByProvider: agentProviderModels,
|
||||
}),
|
||||
[agentProviderDefinitions, agentProviderModels],
|
||||
);
|
||||
|
||||
const displayMode = resolveAgentDisplayMode(availableModes, agent?.currentModeId);
|
||||
|
||||
@@ -1841,7 +1835,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
modeOptions={fallbackModeOptions}
|
||||
selectedModeId={agent.currentModeId ?? undefined}
|
||||
providerDefinitions={agentProviderDefinitions}
|
||||
allProviderModels={agentProviderModels}
|
||||
modelSelectorProviders={agentModelSelectorProviders}
|
||||
onSelectMode={handleSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={modelSelection.activeModelId ?? undefined}
|
||||
@@ -1872,7 +1866,7 @@ export function DraftAgentStatusBar({
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
isModelLoading: _isModelLoading,
|
||||
allProviderModels,
|
||||
modelSelectorProviders,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel,
|
||||
thinkingOptions,
|
||||
@@ -1937,8 +1931,7 @@ export function DraftAgentStatusBar({
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
providers={modelSelectorProviders}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
@@ -1973,7 +1966,7 @@ export function DraftAgentStatusBar({
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider ?? ""}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modelSelectorProviders={modelSelectorProviders}
|
||||
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
|
||||
@@ -74,7 +74,10 @@ import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { normalizeInlinePathTarget } from "@/assistant-file-links";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
normalizeInlinePathTarget,
|
||||
} from "@/assistant-file-links";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
@@ -83,6 +86,7 @@ import {
|
||||
} from "@/workspace/file-open";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
@@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const handleInlinePathPress = useCallback(
|
||||
const handleInlinePathPress = useStableEvent(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (!target.path) {
|
||||
return;
|
||||
@@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
checkout,
|
||||
});
|
||||
},
|
||||
[
|
||||
agent.cwd,
|
||||
agent.projectPlacement?.checkout?.isGit,
|
||||
isMobile,
|
||||
openFileExplorerForCheckout,
|
||||
onOpenWorkspaceFile,
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
setExplorerTabForCheckout,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleToolCallOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
},
|
||||
[handleInlinePathPress],
|
||||
);
|
||||
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
|
||||
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
|
||||
});
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
@@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
belowItem,
|
||||
});
|
||||
return (
|
||||
<AssistantMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={client}
|
||||
serverId={resolvedServerId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenWorkspaceFile={handleInlinePathPress}
|
||||
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(
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
buildModelRows,
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
matchesSearch,
|
||||
resolveProviderLabel,
|
||||
} from "./combined-model-selector.utils";
|
||||
|
||||
describe("combined model selector helpers", () => {
|
||||
const providerDefinitions = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Claude provider",
|
||||
defaultModeId: "default",
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "Codex provider",
|
||||
defaultModeId: "auto",
|
||||
modes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const claudeModels: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "sonnet-4.6",
|
||||
label: "Sonnet 4.6",
|
||||
},
|
||||
];
|
||||
|
||||
const codexModels: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
},
|
||||
];
|
||||
|
||||
it("keeps enough data to search by model and provider name", async () => {
|
||||
const rows = buildModelRows(
|
||||
providerDefinitions,
|
||||
new Map([
|
||||
["claude", claudeModels],
|
||||
["codex", codexModels],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
providerLabel: "Claude",
|
||||
modelLabel: "Sonnet 4.6",
|
||||
modelId: "sonnet-4.6",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
providerLabel: "Codex",
|
||||
modelLabel: "GPT-5.4",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(matchesSearch(rows[0], "claude")).toBe(true);
|
||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches across label, provider, and description with multi-token fuzzy search", () => {
|
||||
const row = {
|
||||
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
|
||||
provider: "opencode",
|
||||
providerLabel: "OpenCode",
|
||||
modelId: "opencode-zen/kimi-k2.5",
|
||||
modelLabel: "Kimi K2.5",
|
||||
description: "OpenCode Zen - kimi",
|
||||
};
|
||||
|
||||
expect(matchesSearch(row, "kimi zen")).toBe(true);
|
||||
expect(matchesSearch(row, "zen kimi")).toBe(true);
|
||||
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
|
||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
||||
});
|
||||
|
||||
it("ranks model search results by fuzzy match quality", () => {
|
||||
const rows = [
|
||||
{
|
||||
favoriteKey: "openai:gpt-4.1",
|
||||
provider: "openai",
|
||||
providerLabel: "OpenAI",
|
||||
modelId: "gpt-4.1",
|
||||
modelLabel: "GPT-4.1",
|
||||
},
|
||||
{
|
||||
favoriteKey: "openai:gpt-5.4",
|
||||
provider: "openai",
|
||||
providerLabel: "OpenAI",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
},
|
||||
{
|
||||
favoriteKey: "google:gemini",
|
||||
provider: "google",
|
||||
providerLabel: "Google",
|
||||
modelId: "gemini",
|
||||
modelLabel: "Gemini",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
const IS_WEB = platformIsWeb;
|
||||
|
||||
@@ -46,12 +45,15 @@ function drillDownRowStyle({
|
||||
}
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
buildModelRows,
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
resolveProviderLabel,
|
||||
type SelectorModelRow,
|
||||
} from "./combined-model-selector.utils";
|
||||
getAllProviderModelRows,
|
||||
getProviderDefaultLabel,
|
||||
getProviderModelRows,
|
||||
resolveSelectedModelLabel,
|
||||
type ProviderSelectionModelRow,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
// TODO: this should be configured per provider in the provider manifest
|
||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||
@@ -61,13 +63,11 @@ type SelectorView =
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
|
||||
interface CombinedModelSelectorProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||
isLoading: boolean;
|
||||
canSelectProvider?: (provider: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
renderTrigger?: (input: {
|
||||
@@ -83,35 +83,26 @@ interface CombinedModelSelectorProps {
|
||||
|
||||
interface SelectorContentProps {
|
||||
view: SelectorView;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
searchQuery: string;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
}
|
||||
|
||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||
if (!models || models.length === 0) {
|
||||
return "Select model";
|
||||
}
|
||||
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function sortFavoritesFirst(
|
||||
rows: SelectorModelRow[],
|
||||
rows: ProviderSelectionModelRow[],
|
||||
favoriteKeys: Set<string>,
|
||||
): SelectorModelRow[] {
|
||||
const favorites: SelectorModelRow[] = [];
|
||||
const rest: SelectorModelRow[] = [];
|
||||
): ProviderSelectionModelRow[] {
|
||||
const favorites: ProviderSelectionModelRow[] = [];
|
||||
const rest: ProviderSelectionModelRow[] = [];
|
||||
for (const row of rows) {
|
||||
if (favoriteKeys.has(row.favoriteKey)) {
|
||||
favorites.push(row);
|
||||
@@ -122,44 +113,17 @@ function sortFavoritesFirst(
|
||||
return [...favorites, ...rest];
|
||||
}
|
||||
|
||||
function groupRowsByProvider(
|
||||
rows: SelectorModelRow[],
|
||||
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.provider);
|
||||
if (existing) {
|
||||
existing.rows.push(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
grouped.set(row.provider, {
|
||||
providerId: row.provider,
|
||||
providerLabel: row.providerLabel,
|
||||
rows: [row],
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
row,
|
||||
isSelected,
|
||||
isFavorite,
|
||||
disabled = false,
|
||||
elevated = false,
|
||||
onPress,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
row: SelectorModelRow;
|
||||
row: ProviderSelectionModelRow;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
disabled?: boolean;
|
||||
elevated?: boolean;
|
||||
onPress: () => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
@@ -181,7 +145,7 @@ function ModelRow({
|
||||
);
|
||||
const trailingSlot = useMemo(
|
||||
() =>
|
||||
onToggleFavorite && !disabled ? (
|
||||
onToggleFavorite ? (
|
||||
<Pressable
|
||||
onPress={handleToggleFavorite}
|
||||
hitSlop={8}
|
||||
@@ -207,7 +171,6 @@ function ModelRow({
|
||||
) : null,
|
||||
[
|
||||
onToggleFavorite,
|
||||
disabled,
|
||||
handleToggleFavorite,
|
||||
isFavorite,
|
||||
row.provider,
|
||||
@@ -225,7 +188,6 @@ function ModelRow({
|
||||
label={row.modelLabel}
|
||||
description={showDescription ? row.description : undefined}
|
||||
selected={isSelected}
|
||||
disabled={disabled}
|
||||
elevated={elevated}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
@@ -235,10 +197,9 @@ function ModelRow({
|
||||
}
|
||||
|
||||
interface SelectableModelRowProps {
|
||||
row: SelectorModelRow;
|
||||
row: ProviderSelectionModelRow;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
disabled?: boolean;
|
||||
elevated?: boolean;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
@@ -248,7 +209,6 @@ function SelectableModelRow({
|
||||
row,
|
||||
isSelected,
|
||||
isFavorite,
|
||||
disabled,
|
||||
elevated,
|
||||
onSelect,
|
||||
onToggleFavorite,
|
||||
@@ -261,7 +221,6 @@ function SelectableModelRow({
|
||||
row={row}
|
||||
isSelected={isSelected}
|
||||
isFavorite={isFavorite}
|
||||
disabled={disabled}
|
||||
elevated={elevated}
|
||||
onPress={handlePress}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
@@ -275,19 +234,15 @@ function FavoritesSection({
|
||||
selectedModel,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
favoriteRows: SelectorModelRow[];
|
||||
favoriteRows: ProviderSelectionModelRow[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme: _theme } = useUnistyles();
|
||||
|
||||
if (favoriteRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -303,7 +258,6 @@ function FavoritesSection({
|
||||
row={row}
|
||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||
disabled={!canSelectProvider(row.provider)}
|
||||
elevated
|
||||
onSelect={onSelect}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
@@ -317,52 +271,68 @@ interface GroupProviderButtonProps {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
rowCount: number;
|
||||
defaultLabel: string | null;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}
|
||||
|
||||
function GroupProviderButton({
|
||||
providerId,
|
||||
providerLabel,
|
||||
rowCount,
|
||||
defaultLabel,
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: GroupProviderButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProvIcon = getProviderIcon(providerId);
|
||||
const handlePress = useCallback(() => {
|
||||
if (defaultLabel) {
|
||||
onSelectDefault(providerId);
|
||||
return;
|
||||
}
|
||||
onDrillDown(providerId, providerLabel);
|
||||
}, [onDrillDown, providerId, providerLabel]);
|
||||
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>
|
||||
{rowCount} {rowCount === 1 ? "model" : "models"}
|
||||
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||
</Text>
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
{defaultLabel ? null : (
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedProviderRows({
|
||||
groupedRows,
|
||||
providers,
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: {
|
||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
||||
providers: ProviderSelectorProvider[];
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{groupedRows.map((group, index) => {
|
||||
{providers.map((provider, index) => {
|
||||
const rows = getProviderModelRows(provider);
|
||||
const defaultLabel = getProviderDefaultLabel(provider);
|
||||
return (
|
||||
<View key={group.providerId}>
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton
|
||||
providerId={group.providerId}
|
||||
providerLabel={group.providerLabel}
|
||||
rowCount={group.rows.length}
|
||||
providerId={provider.id}
|
||||
providerLabel={provider.label}
|
||||
rowCount={rows.length}
|
||||
defaultLabel={defaultLabel}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={onSelectDefault}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -371,22 +341,49 @@ function GroupedProviderRows({
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultProviderRow({
|
||||
providerId,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
providerId: string;
|
||||
isSelected: boolean;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(providerId);
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(providerId, "");
|
||||
}, [onSelect, providerId]);
|
||||
const leadingSlot = useMemo(
|
||||
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label="Default"
|
||||
selected={isSelected}
|
||||
onPress={handlePress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModelRows({
|
||||
rows,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
normalizedQuery,
|
||||
}: {
|
||||
rows: SelectorModelRow[];
|
||||
rows: ProviderSelectionModelRow[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
normalizedQuery: string;
|
||||
}) {
|
||||
@@ -397,19 +394,18 @@ function ProviderModelRows({
|
||||
[favoriteKeys, normalizedQuery, rows],
|
||||
);
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: SelectorModelRow }) => (
|
||||
({ item }: { item: ProviderSelectionModelRow }) => (
|
||||
<SelectableModelRow
|
||||
row={item}
|
||||
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
||||
isFavorite={favoriteKeys.has(item.favoriteKey)}
|
||||
disabled={!canSelectProvider(item.provider)}
|
||||
onSelect={onSelect}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
),
|
||||
[canSelectProvider, favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
||||
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
||||
);
|
||||
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
|
||||
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
|
||||
|
||||
if (useVirtualizedList) {
|
||||
return (
|
||||
@@ -436,44 +432,42 @@ function ProviderModelRows({
|
||||
|
||||
function SelectorContent({
|
||||
view,
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
}: SelectorContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const allRows = useMemo(
|
||||
() => buildModelRows(providerDefinitions, allProviderModels),
|
||||
[allProviderModels, providerDefinitions],
|
||||
);
|
||||
|
||||
const scopedRows = useMemo(() => {
|
||||
if (view.kind === "provider") {
|
||||
return allRows.filter((row) => row.provider === view.providerId);
|
||||
}
|
||||
return allRows;
|
||||
}, [allRows, view]);
|
||||
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
|
||||
const selectedViewProvider = useMemo(
|
||||
() =>
|
||||
view.kind === "provider"
|
||||
? providers.find((provider) => provider.id === view.providerId)
|
||||
: null,
|
||||
[providers, view],
|
||||
);
|
||||
const visibleRows = useMemo(
|
||||
() => filterAndRankModelRows(scopedRows, normalizedQuery),
|
||||
[normalizedQuery, scopedRows],
|
||||
() =>
|
||||
selectedViewProvider
|
||||
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
|
||||
: [],
|
||||
[normalizedQuery, selectedViewProvider],
|
||||
);
|
||||
|
||||
const favoriteRows = useMemo(
|
||||
() => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||
[favoriteKeys, visibleRows],
|
||||
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||
[favoriteKeys, providers],
|
||||
);
|
||||
|
||||
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
|
||||
const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
|
||||
const handleSelectDefaultProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
onSelect(providerId, "");
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||
const emptyState = (
|
||||
<View style={styles.emptyState}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
@@ -482,6 +476,20 @@ function SelectorContent({
|
||||
);
|
||||
|
||||
if (view.kind === "provider") {
|
||||
if (!selectedViewProvider) {
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
|
||||
return (
|
||||
<DefaultProviderRow
|
||||
providerId={view.providerId}
|
||||
isSelected={view.providerId === selectedProvider && !selectedModel}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (visibleRows.length === 0) {
|
||||
return emptyState;
|
||||
}
|
||||
@@ -493,7 +501,6 @@ function SelectorContent({
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={onSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
normalizedQuery={normalizedQuery}
|
||||
/>
|
||||
@@ -508,12 +515,15 @@ function SelectorContent({
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={onSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
|
||||
{allGroupedRows.length > 0 ? (
|
||||
<GroupedProviderRows groupedRows={allGroupedRows} onDrillDown={onDrillDown} />
|
||||
{providers.length > 0 ? (
|
||||
<GroupedProviderRows
|
||||
providers={providers}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={handleSelectDefaultProvider}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!hasResults ? emptyState : null}
|
||||
@@ -522,13 +532,11 @@ function SelectorContent({
|
||||
}
|
||||
|
||||
export function CombinedModelSelector({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isLoading,
|
||||
canSelectProvider = () => true,
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavorite,
|
||||
renderTrigger,
|
||||
@@ -544,26 +552,26 @@ export function CombinedModelSelector({
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
// Single-provider mode: only one provider with models → skip Level 1 entirely
|
||||
// Single-provider mode: only one provider → skip Level 1 entirely
|
||||
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||
const providers = Array.from(allProviderModels.keys());
|
||||
if (providers.length !== 1) return null;
|
||||
const providerId = providers[0];
|
||||
const label = resolveProviderLabel(providerDefinitions, providerId);
|
||||
return { kind: "provider", providerId, providerLabel: label };
|
||||
}, [allProviderModels, providerDefinitions]);
|
||||
const provider = providers[0];
|
||||
if (!provider) return null;
|
||||
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
|
||||
}, [providers]);
|
||||
|
||||
const computeInitialView = useCallback((): SelectorView => {
|
||||
if (singleProviderView) return singleProviderView;
|
||||
|
||||
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
|
||||
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
|
||||
const label = resolveProviderLabel(providerDefinitions, selectedProvider);
|
||||
return { kind: "provider", providerId: selectedProvider, providerLabel: label };
|
||||
const provider = providers.find((entry) => entry.id === selectedProvider);
|
||||
if (provider)
|
||||
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
|
||||
}
|
||||
|
||||
return { kind: "all" };
|
||||
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]);
|
||||
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providers]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -594,28 +602,22 @@ export function CombinedModelSelector({
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
if (!selectedModel) {
|
||||
if (!hasSelectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
if (!models) {
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const model = models.find((entry) => entry.id === selectedModel);
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
||||
return resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
isLoading,
|
||||
});
|
||||
}, [isLoading, providers, selectedModel, selectedProvider]);
|
||||
|
||||
const desktopFixedHeight = useMemo(() => {
|
||||
if (view.kind !== "provider") {
|
||||
return undefined;
|
||||
}
|
||||
const models = allProviderModels.get(view.providerId);
|
||||
const modelCount = models?.length ?? 0;
|
||||
const provider = providers.find((entry) => entry.id === view.providerId);
|
||||
const modelCount = provider ? getProviderModelRows(provider).length : 0;
|
||||
return Math.min(80 + modelCount * 40, 400);
|
||||
}, [allProviderModels, view]);
|
||||
}, [providers, view]);
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||
@@ -750,14 +752,12 @@ export function CombinedModelSelector({
|
||||
{isContentReady ? (
|
||||
<SelectorContent
|
||||
view={view}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
providers={providers}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
searchQuery={searchQuery}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={handleSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={handleDrillDown}
|
||||
/>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
|
||||
export type SelectorModelRow = FavoriteModelRow;
|
||||
|
||||
export function resolveProviderLabel(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
providerId: string,
|
||||
): string {
|
||||
return (
|
||||
providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
export function buildModelRows(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||
): SelectorModelRow[] {
|
||||
const providerLabelMap = new Map(
|
||||
providerDefinitions.map((definition) => [definition.id, definition.label]),
|
||||
);
|
||||
const rows: SelectorModelRow[] = [];
|
||||
|
||||
for (const definition of providerDefinitions) {
|
||||
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
|
||||
for (const model of allProviderModels.get(definition.id) ?? []) {
|
||||
rows.push({
|
||||
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
||||
provider: definition.id,
|
||||
providerLabel,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
||||
return scoreModelRow(row, normalizedQuery) !== null;
|
||||
}
|
||||
|
||||
function getModelRowSearchFields(row: SelectorModelRow): string[] {
|
||||
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
|
||||
}
|
||||
|
||||
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) {
|
||||
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
|
||||
}
|
||||
|
||||
export function filterAndRankModelRows(
|
||||
rows: SelectorModelRow[],
|
||||
normalizedQuery: string,
|
||||
): SelectorModelRow[] {
|
||||
if (!normalizedQuery) return rows;
|
||||
const scored = rows
|
||||
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
|
||||
.filter((entry): entry is { row: SelectorModelRow; score: NonNullable<typeof entry.score> } =>
|
||||
Boolean(entry.score),
|
||||
);
|
||||
|
||||
scored.sort((a, b) => {
|
||||
const cmp = compareMatchScores(a.score, b.score);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.row.modelLabel.localeCompare(b.row.modelLabel);
|
||||
});
|
||||
|
||||
return scored.map((entry) => entry.row);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ describe("resolveStatusControlMode", () => {
|
||||
selectedModel: "",
|
||||
onSelectModel: () => undefined,
|
||||
isModelLoading: false,
|
||||
allProviderModels: new Map(),
|
||||
modelSelectorProviders: [],
|
||||
isAllModelsLoading: false,
|
||||
onSelectProviderAndModel: () => undefined,
|
||||
thinkingOptions: [],
|
||||
|
||||
@@ -463,7 +463,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) {
|
||||
accessibilityLabel="Send queued message now"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowUp size={ICON_SIZE.sm} color="white" />
|
||||
<ThemedArrowUp size={ICON_SIZE.sm} uniProps={iconAccentForegroundMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -756,7 +756,8 @@ function ComposerRightControlsSlot({
|
||||
...voiceProps
|
||||
}: ComposerRightControlsSlotProps) {
|
||||
const hideVoiceForCompactInput = isCompact && hasSendableContent;
|
||||
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !hideVoiceForCompactInput;
|
||||
const showVoiceModeButton =
|
||||
!isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
|
||||
const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing;
|
||||
if (!showVoiceModeButton && !shouldShowCancelButton) return null;
|
||||
return (
|
||||
@@ -1846,6 +1847,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
const QUEUE_SEND_BUTTON_STYLE = [styles.queueActionButton, styles.queueSendButton];
|
||||
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedAudioLines = withUnistyles(AudioLines);
|
||||
@@ -1854,3 +1856,4 @@ const ThemedGithub = withUnistyles(Github);
|
||||
|
||||
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
||||
|
||||
@@ -166,16 +166,16 @@ export function DictationOverlay({
|
||||
[actionsDisabled, isFailed],
|
||||
);
|
||||
const overlayTimerTextStyle = useMemo(
|
||||
() => [overlayStyles.timerText, { color: theme.colors.palette.white }],
|
||||
[theme.colors.palette.white],
|
||||
() => [overlayStyles.timerText, { color: theme.colors.accentForeground }],
|
||||
[theme.colors.accentForeground],
|
||||
);
|
||||
const overlayTranscriptTextStyle = useMemo(
|
||||
() => [overlayStyles.transcriptText, { color: theme.colors.palette.white, opacity: 0.95 }],
|
||||
[theme.colors.palette.white],
|
||||
() => [overlayStyles.transcriptText, { color: theme.colors.accentForeground, opacity: 0.95 }],
|
||||
[theme.colors.accentForeground],
|
||||
);
|
||||
const overlayRetryButtonStyle = useMemo(
|
||||
() => [overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }],
|
||||
[theme.colors.palette.white],
|
||||
() => [overlayStyles.actionButton, { backgroundColor: theme.colors.accentForeground }],
|
||||
[theme.colors.accentForeground],
|
||||
);
|
||||
const overlayConfirmButtonStyle = overlayRetryButtonStyle;
|
||||
|
||||
@@ -192,7 +192,7 @@ export function DictationOverlay({
|
||||
accessibilityLabel="Cancel dictation"
|
||||
style={overlayCancelButtonStyle}
|
||||
>
|
||||
<X size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
|
||||
<X size={theme.iconSize.lg} color={theme.colors.accentForeground} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
|
||||
<View style={overlayStyles.centerContainer}>
|
||||
@@ -202,7 +202,7 @@ export function DictationOverlay({
|
||||
isMuted={false}
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
color={theme.colors.palette.white}
|
||||
color={theme.colors.accentForeground}
|
||||
/>
|
||||
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
@@ -216,7 +216,7 @@ export function DictationOverlay({
|
||||
<View style={overlayStyles.actionButtonsContainer}>
|
||||
{actionsDisabled ? (
|
||||
<View style={overlayStyles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.palette.white} />
|
||||
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
|
||||
</View>
|
||||
) : null}
|
||||
{!actionsDisabled && isFailed ? (
|
||||
@@ -239,7 +239,7 @@ export function DictationOverlay({
|
||||
>
|
||||
<Pencil
|
||||
size={theme.iconSize.lg}
|
||||
color={theme.colors.palette.white}
|
||||
color={theme.colors.accentForeground}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
@@ -315,12 +315,12 @@ function SendButtonContent({
|
||||
buttonIconSize: number;
|
||||
}) {
|
||||
if (isSubmitLoading) {
|
||||
return <ActivityIndicator size="small" color="white" />;
|
||||
return <ThemedActivityIndicator size="small" uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
if (submitIcon === "return") {
|
||||
return <CornerDownLeft size={buttonIconSize} color="white" />;
|
||||
return <ThemedCornerDownLeft size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
return <ArrowUp size={buttonIconSize} color="white" />;
|
||||
return <ThemedArrowUp size={buttonIconSize} uniProps={iconAccentForegroundMapping} />;
|
||||
}
|
||||
|
||||
function resolveSubmitAccessibilityLabel(input: {
|
||||
@@ -799,6 +799,7 @@ interface ToggleRealtimeVoiceContext {
|
||||
voiceAgentId: string | undefined;
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
isAgentRunning: boolean;
|
||||
handleStopRealtimeVoice: () => Promise<unknown> | void;
|
||||
toast: { error: (msg: string) => void };
|
||||
}
|
||||
@@ -812,6 +813,10 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
void ctx.handleStopRealtimeVoice();
|
||||
return;
|
||||
}
|
||||
if (ctx.isAgentRunning) {
|
||||
ctx.toast.error("Interrupt the agent before starting voice mode");
|
||||
return;
|
||||
}
|
||||
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
|
||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||
const message = extractErrorMessage(error);
|
||||
@@ -1450,10 +1455,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
voiceAgentId,
|
||||
isConnected,
|
||||
disabled,
|
||||
isAgentRunning,
|
||||
handleStopRealtimeVoice,
|
||||
toast,
|
||||
});
|
||||
}, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]);
|
||||
}, [
|
||||
disabled,
|
||||
handleStopRealtimeVoice,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
toast,
|
||||
voice,
|
||||
voiceAgentId,
|
||||
voiceServerId,
|
||||
]);
|
||||
|
||||
const minimizeInputHeight = useCallback(() => {
|
||||
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
||||
@@ -1962,10 +1977,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedMic = withUnistyles(Mic);
|
||||
const ThemedMicOff = withUnistyles(MicOff);
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
const ThemedCornerDownLeft = withUnistyles(CornerDownLeft);
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedTextInput = withUnistyles(TextInput);
|
||||
|
||||
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
||||
const textInputPlaceholderColorMapping = (theme: Theme) => ({
|
||||
placeholderTextColor: theme.colors.surface4,
|
||||
});
|
||||
|
||||
@@ -68,9 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
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 { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
@@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import {
|
||||
AssistantInlineCodePathLink,
|
||||
classifyAssistantFileLink,
|
||||
type AssistantFileLinkSource,
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
type InlinePathTarget,
|
||||
useAssistantFileLinkResolver,
|
||||
useAssistantFileLinkActions,
|
||||
} from "@/assistant-file-links";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
@@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({
|
||||
interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
client?: DaemonClient | null;
|
||||
toast?: ToastApi | null;
|
||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||
}
|
||||
|
||||
@@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie
|
||||
export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp: _timestamp,
|
||||
onInlinePathPress,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
client,
|
||||
toast,
|
||||
spacing = "default",
|
||||
}: AssistantMessageProps) {
|
||||
const markdownParser = useMemo(() => {
|
||||
@@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
return parser;
|
||||
}, []);
|
||||
|
||||
const fileLinkResolver = useAssistantFileLinkResolver({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile: onInlinePathPress,
|
||||
toast,
|
||||
const fileLinkActions = useAssistantFileLinkActions();
|
||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||
fileLinkActions.open({ href: url }, "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;
|
||||
});
|
||||
|
||||
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>(() => {
|
||||
return {
|
||||
text: (
|
||||
@@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
) => {
|
||||
const content = node.content ?? "";
|
||||
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
||||
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
|
||||
const inlineCodeSource: AssistantFileLinkSource = {
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
};
|
||||
const shouldResolveInlinePath =
|
||||
onInlinePathPress &&
|
||||
!isLinkedInlineCode &&
|
||||
inlineCodeFileLink &&
|
||||
inlineCodeFileLink.kind !== "external";
|
||||
!isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource);
|
||||
|
||||
if (shouldResolveInlinePath) {
|
||||
return (
|
||||
@@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={styles.code_inline}
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={styles.code_inline}
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{content}
|
||||
</AssistantMarkdownCodeLink>
|
||||
@@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
key={node.key}
|
||||
source={getMarkdownLinkSource(node)}
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
workspaceRoot={workspaceRoot}
|
||||
>
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
@@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
handleLinkPrefetch,
|
||||
handleLinkPress,
|
||||
markdownParser,
|
||||
onInlinePathPress,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
]);
|
||||
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
|
||||
|
||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||
const keyedBlocks = useMemo(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("lucide-react-native", () => ({
|
||||
Bot: function Bot() {
|
||||
return null;
|
||||
},
|
||||
PackagePlus: function PackagePlus() {
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { getProviderIcon } from "./provider-icons";
|
||||
|
||||
describe("getProviderIcon", () => {
|
||||
it("keeps built-in provider icons", () => {
|
||||
expect(getProviderIcon("kiro")).toBe(PackagePlus);
|
||||
});
|
||||
|
||||
it("uses vendored ACP catalog icons for catalog provider ids", () => {
|
||||
const icon = getProviderIcon("amp-acp");
|
||||
|
||||
expect(icon).not.toBe(Bot);
|
||||
expect(getProviderIcon("amp-acp")).toBe(icon);
|
||||
});
|
||||
|
||||
it("falls back to the robot icon for unknown custom providers", () => {
|
||||
expect(getProviderIcon("custom-claude-profile")).toBe(Bot);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,63 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { createElement, type ComponentType } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
copilot: CopilotIcon as unknown as typeof Bot,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
pi: PiIcon as unknown as typeof Bot,
|
||||
export interface ProviderIconProps {
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
|
||||
|
||||
const PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||
claude: ClaudeIcon as unknown as ProviderIconComponent,
|
||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||
kiro: PackagePlus,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
pi: PiIcon as unknown as ProviderIconComponent,
|
||||
};
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
return PROVIDER_ICONS[provider] ?? Bot;
|
||||
const CATALOG_ICON_SVGS = new Map(
|
||||
ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [[entry.id, entry.iconSvg]] : [])),
|
||||
);
|
||||
|
||||
const catalogIconComponents = new Map<string, ProviderIconComponent>();
|
||||
|
||||
function createCatalogIcon(provider: string, iconSvg: string): ProviderIconComponent {
|
||||
const CatalogProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
||||
createElement(SvgXml, {
|
||||
xml: iconSvg,
|
||||
width: size,
|
||||
height: size,
|
||||
color,
|
||||
});
|
||||
CatalogProviderIcon.displayName = `CatalogProviderIcon(${provider})`;
|
||||
return CatalogProviderIcon;
|
||||
}
|
||||
|
||||
function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined {
|
||||
const cached = catalogIconComponents.get(provider);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const iconSvg = CATALOG_ICON_SVGS.get(provider);
|
||||
if (!iconSvg) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const icon = createCatalogIcon(provider, iconSvg);
|
||||
catalogIconComponents.set(provider, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string): ProviderIconComponent {
|
||||
return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? 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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { StyleProp, TextStyle } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
interface SettingsTextAreaProps {
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
testID?: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
export function SettingsTextArea({
|
||||
accessibilityLabel,
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
testID,
|
||||
style,
|
||||
}: SettingsTextAreaProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const inputStyle = useMemo(() => [styles.input, style], [style]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
multiline
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsTextAreaCard(props: SettingsTextAreaProps) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
<SettingsTextArea {...props} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
input: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
minHeight: 96,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
}));
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
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 {
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -45,6 +48,7 @@ import {
|
||||
SquareTerminal,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react-native";
|
||||
@@ -147,6 +151,7 @@ const ThemedTrash2 = withUnistyles(Trash2);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -232,6 +237,7 @@ interface WorkspaceRowInnerProps {
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
@@ -565,6 +571,7 @@ const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColor
|
||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
@@ -640,6 +647,7 @@ function WorkspaceRowRightGroup({
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
isHovered: boolean;
|
||||
@@ -656,6 +664,7 @@ function WorkspaceRowRightGroup({
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
}) {
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
return (
|
||||
@@ -675,6 +684,7 @@ function WorkspaceRowRightGroup({
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -701,6 +711,7 @@ function WorkspaceKebabMenu({
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -710,6 +721,7 @@ function WorkspaceKebabMenu({
|
||||
workspaceKey: string;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
@@ -750,6 +762,15 @@ function WorkspaceKebabMenu({
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
Rename workspace
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
@@ -1319,6 +1340,7 @@ function WorkspaceRowInner({
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const _isCompact = useIsCompactFormFactor();
|
||||
@@ -1429,6 +1451,7 @@ function WorkspaceRowInner({
|
||||
onArchive={onArchive}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
/>
|
||||
</View>
|
||||
{prHint ? (
|
||||
@@ -1469,7 +1492,9 @@ function WorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
@@ -1599,6 +1624,51 @@ function WorkspaceRowWithMenu({
|
||||
toast.copied("Branch name copied");
|
||||
}, [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");
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -1617,26 +1687,41 @@ function WorkspaceRowWithMenu({
|
||||
});
|
||||
|
||||
return (
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<>
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
|
||||
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
|
||||
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
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;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
@@ -362,6 +363,7 @@ export function SplitContainer({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -577,6 +579,7 @@ export function SplitContainer({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -716,6 +719,7 @@ function SplitNodeView({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -768,6 +772,7 @@ function SplitNodeView({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -813,6 +818,7 @@ function SplitNodeView({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -864,6 +870,7 @@ function SplitPaneView({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -1004,6 +1011,7 @@ function SplitPaneView({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
|
||||
@@ -31,6 +31,15 @@ const CATALOG_DATA = [
|
||||
installLink: "https://github.com/tao12345666333/amp-acp",
|
||||
command: ["amp-acp"],
|
||||
},
|
||||
{
|
||||
id: "antigravity",
|
||||
title: "Google Antigravity CLI",
|
||||
description: "Terminal-first surface to interact with Antigravity agents",
|
||||
version: "1.0.0",
|
||||
iconId: "antigravity",
|
||||
installLink: "https://www.antigravity.google/product/antigravity-cli",
|
||||
command: ["paseo", "acp", "antigravity"],
|
||||
},
|
||||
{
|
||||
id: "auggie",
|
||||
title: "Auggie CLI",
|
||||
@@ -135,6 +144,15 @@ const CATALOG_DATA = [
|
||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.7"],
|
||||
},
|
||||
{
|
||||
id: "deepseek-tui",
|
||||
title: "DeepSeek TUI",
|
||||
description: "Terminal coding agent for DeepSeek V4",
|
||||
version: "0.8.39",
|
||||
iconId: "deepseek-tui",
|
||||
installLink: "https://github.com/Hmbown/DeepSeek-TUI",
|
||||
command: ["deepseek", "serve", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
@@ -240,6 +258,15 @@ const CATALOG_DATA = [
|
||||
installLink: "https://kilo.ai/docs/code-with-ai/platforms/cli",
|
||||
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",
|
||||
title: "Kimi CLI",
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("ACP provider catalog", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
for (const entry of entriesWithIcons) {
|
||||
@@ -36,9 +36,12 @@ describe("ACP provider catalog", () => {
|
||||
|
||||
it("uses PATH commands for entries that were binary distributions upstream", () => {
|
||||
expect(findProvider("amp-acp").command).toEqual(["amp-acp"]);
|
||||
expect(findProvider("antigravity").command).toEqual(["paseo", "acp", "antigravity"]);
|
||||
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
|
||||
expect(findProvider("deepseek-tui").command).toEqual(["deepseek", "serve", "--acp"]);
|
||||
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
|
||||
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
|
||||
expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]);
|
||||
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
|
||||
});
|
||||
|
||||
@@ -56,6 +59,14 @@ describe("ACP provider catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("bundles the Antigravity catalog metadata and icon", () => {
|
||||
const provider = findProvider("antigravity");
|
||||
|
||||
expect(provider.description).toBe("Terminal-first surface to interact with Antigravity agents");
|
||||
expect(provider.installLink).toBe("https://www.antigravity.google/product/antigravity-cli");
|
||||
expect(provider.iconSvg).toContain("<svg");
|
||||
});
|
||||
|
||||
it("preserves provider env in the daemon config patch", () => {
|
||||
const patch = buildAcpProviderConfigPatch(findProvider("auggie"));
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import {
|
||||
buildSelectableProviderSelectorProviders,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
@@ -28,9 +32,9 @@ import {
|
||||
SELECTABLE_PROVIDER_STATUSES,
|
||||
type FormInitialValues,
|
||||
type FormState,
|
||||
} from "./resolve-agent-form";
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
export type { FormInitialValues } from "./resolve-agent-form";
|
||||
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
export interface UseAgentFormStateOptions {
|
||||
initialServerId?: string | null;
|
||||
@@ -63,6 +67,7 @@ export interface UseAgentFormStateResult {
|
||||
modeOptions: AgentMode[];
|
||||
availableModels: AgentModelDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
isAllModelsLoading: boolean;
|
||||
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
isModelLoading: boolean;
|
||||
@@ -237,6 +242,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
() => buildAllProviderModels(snapshotEntries),
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotModelSelectorProviders = useMemo(
|
||||
() => buildSelectableProviderSelectorProviders(snapshotEntries),
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotSelectedEntry = useMemo(
|
||||
() =>
|
||||
formState.provider
|
||||
@@ -255,6 +264,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
|
||||
const allProviderModels = snapshotAllProviderModels;
|
||||
const modelSelectorProviders = snapshotModelSelectorProviders;
|
||||
const availableModels = snapshotSelectedProviderModels;
|
||||
const modeOptions = snapshotSelectedProviderModes;
|
||||
const isAllModelsLoading = snapshotIsLoading || selectedProviderIsLoading;
|
||||
@@ -516,6 +526,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
modeOptions,
|
||||
availableModels: availableModels ?? [],
|
||||
allProviderModels,
|
||||
modelSelectorProviders,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
@@ -548,6 +559,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
modelSelectorProviders,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export interface DraftKeyContext {
|
||||
selectedServerId: string | null;
|
||||
@@ -20,56 +18,6 @@ export function resolveDraftKey(input: {
|
||||
return input.draftKey;
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerModelId(input: {
|
||||
selectedModel: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
}): string {
|
||||
return input.selectedModel.trim();
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerThinkingOptionId(input: {
|
||||
selectedThinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
effectiveModelId: string;
|
||||
}): string {
|
||||
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftComposerCommandConfig(input: {
|
||||
provider: AgentProvider | null;
|
||||
cwd: string;
|
||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||
selectedMode: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
...(input.modeOptions.length > 0 && input.selectedMode !== ""
|
||||
? { modeId: input.selectedMode }
|
||||
: {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDraftStatusControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
features?: DraftAgentStatusBarProps["features"];
|
||||
@@ -88,7 +36,7 @@ export function buildDraftStatusControls(input: {
|
||||
selectedModel: formState.selectedModel,
|
||||
onSelectModel: formState.setModelFromUser,
|
||||
isModelLoading: formState.isModelLoading,
|
||||
allProviderModels: formState.allProviderModels,
|
||||
modelSelectorProviders: formState.modelSelectorProviders,
|
||||
isAllModelsLoading: formState.isAllModelsLoading,
|
||||
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
||||
thinkingOptions: formState.availableThinkingOptions,
|
||||
|
||||
@@ -81,6 +81,25 @@ vi.mock("./use-agent-form-state", () => ({
|
||||
],
|
||||
],
|
||||
]),
|
||||
modelSelectorProviders: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
isAllModelsLoading: false,
|
||||
availableThinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDraftKey } from "./use-agent-input-draft-core";
|
||||
import {
|
||||
buildDraftComposerCommandConfig,
|
||||
resolveDraftKey,
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
} from "./use-agent-input-draft-core";
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
describe("resolveDraftKey", () => {
|
||||
it("returns a string draft key unchanged", () => {
|
||||
@@ -30,8 +30,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
||||
it("returns the selected model trimmed", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: " gpt-5.4-mini ",
|
||||
provider: "codex",
|
||||
modelId: " gpt-5.4-mini ",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
@@ -39,8 +43,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
||||
it("returns empty string when no model selected", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
provider: "codex",
|
||||
modelId: "",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
@@ -63,21 +71,33 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
|
||||
it("prefers the selected thinking option when present", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
resolveEffectiveComposerThinkingOptionId(
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
modeOptions: [],
|
||||
},
|
||||
"gpt-5.4",
|
||||
),
|
||||
).toBe("medium");
|
||||
});
|
||||
|
||||
it("falls back to the model default thinking option", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
resolveEffectiveComposerThinkingOptionId(
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: models,
|
||||
modeOptions: [],
|
||||
},
|
||||
"gpt-5.4",
|
||||
),
|
||||
).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -85,11 +105,16 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
describe("buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
},
|
||||
cwd: " ",
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
@@ -98,11 +123,16 @@ describe("buildDraftComposerCommandConfig", () => {
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
},
|
||||
cwd: "/repo",
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
selectedMode: "auto",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
|
||||
@@ -10,14 +10,17 @@ import {
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import {
|
||||
areAttachmentsEqual,
|
||||
buildDraftComposerCommandConfig,
|
||||
buildDraftStatusControls,
|
||||
hasDraftContent,
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type DraftKeyInput,
|
||||
} from "@/hooks/use-agent-input-draft-core";
|
||||
import {
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
|
||||
type AttachmentUpdater =
|
||||
@@ -199,23 +202,33 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
formState.setWorkingDir(lockedWorkingDir);
|
||||
}, [composerOptions, formState, lockedWorkingDir]);
|
||||
|
||||
const providerSelection = useMemo<ProviderSelectionState>(
|
||||
() => ({
|
||||
provider: formState.selectedProvider,
|
||||
modelId: formState.selectedModel,
|
||||
modeId: formState.selectedMode,
|
||||
thinkingOptionId: formState.selectedThinkingOptionId,
|
||||
availableModels: formState.availableModels,
|
||||
modeOptions: formState.modeOptions,
|
||||
}),
|
||||
[
|
||||
formState.availableModels,
|
||||
formState.modeOptions,
|
||||
formState.selectedMode,
|
||||
formState.selectedModel,
|
||||
formState.selectedProvider,
|
||||
formState.selectedThinkingOptionId,
|
||||
],
|
||||
);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: formState.selectedModel,
|
||||
availableModels: formState.availableModels,
|
||||
}),
|
||||
[formState.availableModels, formState.selectedModel],
|
||||
() => resolveEffectiveComposerModelId(providerSelection),
|
||||
[providerSelection],
|
||||
);
|
||||
|
||||
const effectiveThinkingOptionId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
availableModels: formState.availableModels,
|
||||
effectiveModelId,
|
||||
}),
|
||||
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
||||
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
|
||||
[effectiveModelId, providerSelection],
|
||||
);
|
||||
|
||||
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||
@@ -236,11 +249,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
const commandDraftConfig = useMemo(
|
||||
() =>
|
||||
composerOptions
|
||||
? buildDraftComposerCommandConfig({
|
||||
provider: formState.selectedProvider,
|
||||
? buildDraftCommandConfig({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
@@ -251,10 +262,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatureValues,
|
||||
providerSelection,
|
||||
workingDir,
|
||||
formState.modeOptions,
|
||||
formState.selectedMode,
|
||||
formState.selectedProvider,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -303,6 +312,7 @@ export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftComposerCommandConfig,
|
||||
buildDraftCommandConfig,
|
||||
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||
buildDraftStatusControls,
|
||||
};
|
||||
|
||||
@@ -148,7 +148,7 @@ async function emitCheckoutStatusUpdatePrStatus(payload: CheckoutPrStatusPayload
|
||||
|
||||
function unsupportedTimelineError(): Error {
|
||||
const error = new Error(
|
||||
"Unknown request schema requestType=pull_request_timeline_request code=unknown_schema",
|
||||
"Unknown request, try upgrading the daemon (currently v0.1.78) requestType=pull_request_timeline_request code=unknown_schema",
|
||||
) as Error & { code: string; requestType: string };
|
||||
error.name = "DaemonRpcError";
|
||||
error.code = "unknown_schema";
|
||||
|
||||
@@ -19,6 +19,9 @@ export function resolvePreferredEditorId(
|
||||
availableEditorIds: readonly EditorTargetId[],
|
||||
storedEditorId: EditorTargetId | null | undefined,
|
||||
): EditorTargetId | null {
|
||||
if (storedEditorId === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
storedEditorId &&
|
||||
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
|
||||
@@ -50,7 +53,7 @@ export function usePreferredEditor() {
|
||||
);
|
||||
|
||||
return {
|
||||
preferredEditorId: data ?? null,
|
||||
preferredEditorId: isPending ? undefined : (data ?? null),
|
||||
isLoading: isPending,
|
||||
updatePreferredEditor,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
buildProviderSelectorProviders,
|
||||
buildSelectableProviderSelectorProviders,
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
matchesModelSearch,
|
||||
resolveSelectedModelLabel,
|
||||
resolveSubmissionReadiness,
|
||||
} from "./provider-selection";
|
||||
|
||||
describe("combined model selector data", () => {
|
||||
const codexModel: AgentModelDefinition = {
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
};
|
||||
|
||||
function snapshotEntry(
|
||||
overrides: Partial<ProviderSnapshotEntry> & Pick<ProviderSnapshotEntry, "provider">,
|
||||
): ProviderSnapshotEntry {
|
||||
return {
|
||||
...overrides,
|
||||
provider: overrides.provider,
|
||||
status: overrides.status ?? "ready",
|
||||
enabled: overrides.enabled ?? true,
|
||||
label: overrides.label ?? overrides.provider,
|
||||
description: overrides.description ?? `${overrides.provider} provider`,
|
||||
defaultModeId: overrides.defaultModeId ?? "default",
|
||||
modes: overrides.modes ?? [],
|
||||
models: overrides.models ?? [codexModel],
|
||||
};
|
||||
}
|
||||
|
||||
it("builds selector providers from ready enabled snapshot entries", () => {
|
||||
expect(
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
models: [codexModel],
|
||||
}),
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
description: undefined,
|
||||
isDefault: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("represents ready enabled providers without explicit models as provider-default selection", () => {
|
||||
expect(
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
models: [],
|
||||
}),
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
id: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
modelSelection: { kind: "providerDefault", label: "Default" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes disabled providers from selector data", () => {
|
||||
expect(
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
enabled: false,
|
||||
models: [],
|
||||
}),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes providers that are not ready", () => {
|
||||
expect(
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
||||
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
|
||||
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds selector providers from an already-curated provider list", () => {
|
||||
const providerDefinitions: AgentProviderDefinition[] = [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "Codex provider",
|
||||
defaultModeId: "auto",
|
||||
modes: [],
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
buildProviderSelectorProviders({
|
||||
providerDefinitions,
|
||||
modelsByProvider: new Map([["codex", [codexModel]]]),
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches across label, provider, and description with multi-token fuzzy search", () => {
|
||||
const row = {
|
||||
favoriteKey: "opencode:opencode-zen/kimi-k2.5",
|
||||
provider: "opencode",
|
||||
providerLabel: "OpenCode",
|
||||
modelId: "opencode-zen/kimi-k2.5",
|
||||
modelLabel: "Kimi K2.5",
|
||||
description: "OpenCode Zen - kimi",
|
||||
};
|
||||
|
||||
expect(matchesModelSearch(row, "kimi zen")).toBe(true);
|
||||
expect(matchesModelSearch(row, "zen kimi")).toBe(true);
|
||||
expect(matchesModelSearch(row, "k2.5 zen")).toBe(true);
|
||||
expect(matchesModelSearch(row, "kimi gemini")).toBe(false);
|
||||
});
|
||||
|
||||
it("ranks model search results by fuzzy match quality", () => {
|
||||
const rows = [
|
||||
{
|
||||
favoriteKey: "openai:gpt-4.1",
|
||||
provider: "openai",
|
||||
providerLabel: "OpenAI",
|
||||
modelId: "gpt-4.1",
|
||||
modelLabel: "GPT-4.1",
|
||||
},
|
||||
{
|
||||
favoriteKey: "openai:gpt-5.4",
|
||||
provider: "openai",
|
||||
providerLabel: "OpenAI",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
},
|
||||
{
|
||||
favoriteKey: "google:gemini",
|
||||
provider: "google",
|
||||
providerLabel: "Google",
|
||||
modelId: "gemini",
|
||||
modelLabel: "Gemini",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
});
|
||||
|
||||
it("resolves selected labels from explicit provider model-selection state", () => {
|
||||
const providers = buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
models: [codexModel],
|
||||
}),
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
models: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider: "codex",
|
||||
selectedModel: "gpt-5.4",
|
||||
isLoading: false,
|
||||
}),
|
||||
).toBe("GPT-5.4");
|
||||
expect(
|
||||
resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider: "deepseek-tui",
|
||||
selectedModel: "",
|
||||
isLoading: false,
|
||||
}),
|
||||
).toBe("Default");
|
||||
});
|
||||
|
||||
it("returns observable submission readiness reasons", () => {
|
||||
expect(
|
||||
resolveSubmissionReadiness({
|
||||
text: "hello",
|
||||
allowsEmptyAutoSubmit: false,
|
||||
providerCount: 1,
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "",
|
||||
availableModels: [codexModel],
|
||||
isModelLoading: false,
|
||||
},
|
||||
autoSubmitConfig: null,
|
||||
workspaceDirectory: "/repo",
|
||||
hasClient: true,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "No model is available for the selected provider",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSubmissionReadiness({
|
||||
text: "hello",
|
||||
allowsEmptyAutoSubmit: false,
|
||||
providerCount: 1,
|
||||
selection: {
|
||||
provider: "deepseek-tui",
|
||||
modelId: "",
|
||||
availableModels: [],
|
||||
isModelLoading: false,
|
||||
},
|
||||
autoSubmitConfig: null,
|
||||
workspaceDirectory: "/repo",
|
||||
hasClient: true,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
|
||||
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
|
||||
|
||||
export type ProviderModelSelection =
|
||||
| { kind: "models"; rows: ProviderSelectionModelRow[] }
|
||||
| { kind: "providerDefault"; label: string }
|
||||
| { kind: "loading" }
|
||||
| { kind: "unavailable"; reason: string };
|
||||
|
||||
export interface ProviderSelectorProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
modelSelection: ProviderModelSelection;
|
||||
}
|
||||
|
||||
export interface ProviderSelectionState {
|
||||
provider: AgentProvider | null;
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
modeOptions: AgentMode[];
|
||||
}
|
||||
|
||||
export interface ProviderSelectionReadiness {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function buildModelRows(
|
||||
provider: string,
|
||||
providerLabel: string,
|
||||
models: AgentModelDefinition[],
|
||||
): ProviderSelectionModelRow[] {
|
||||
return models.map((model) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: model.id }),
|
||||
provider,
|
||||
providerLabel,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildModelSelection(
|
||||
provider: string,
|
||||
providerLabel: string,
|
||||
models: AgentModelDefinition[] | null,
|
||||
): ProviderModelSelection {
|
||||
if (models === null) {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
if (models.length === 0) {
|
||||
return { kind: "providerDefault", label: "Default" };
|
||||
}
|
||||
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
|
||||
}
|
||||
|
||||
function isSelectableProvider(entry: ProviderSnapshotEntry): boolean {
|
||||
return entry.enabled && entry.status === "ready";
|
||||
}
|
||||
|
||||
export function buildProviderSelectorProviders(input: {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modelsByProvider: Map<string, AgentModelDefinition[]>;
|
||||
}): ProviderSelectorProvider[] {
|
||||
return input.providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
modelSelection: buildModelSelection(
|
||||
definition.id,
|
||||
definition.label,
|
||||
input.modelsByProvider.has(definition.id)
|
||||
? (input.modelsByProvider.get(definition.id) ?? [])
|
||||
: null,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSelectableProviderSelectorProviders(
|
||||
entries: ProviderSnapshotEntry[] | undefined,
|
||||
): ProviderSelectorProvider[] {
|
||||
return (entries ?? []).filter(isSelectableProvider).map((entry) => {
|
||||
const label = entry.label ?? entry.provider;
|
||||
return {
|
||||
id: entry.provider,
|
||||
label,
|
||||
modelSelection: buildModelSelection(entry.provider, label, entry.models ?? []),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getProviderModelRows(
|
||||
provider: ProviderSelectorProvider,
|
||||
): ProviderSelectionModelRow[] {
|
||||
return provider.modelSelection.kind === "models" ? provider.modelSelection.rows : [];
|
||||
}
|
||||
|
||||
export function getAllProviderModelRows(
|
||||
providers: ProviderSelectorProvider[],
|
||||
): ProviderSelectionModelRow[] {
|
||||
return providers.flatMap(getProviderModelRows);
|
||||
}
|
||||
|
||||
export function getProviderDefaultLabel(provider: ProviderSelectorProvider): string | null {
|
||||
return provider.modelSelection.kind === "providerDefault" ? provider.modelSelection.label : null;
|
||||
}
|
||||
|
||||
export function resolveSelectedModelLabel(input: {
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
isLoading: boolean;
|
||||
}): string {
|
||||
const selectedProvider = input.selectedProvider.trim();
|
||||
if (!selectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
|
||||
const provider = input.providers.find((entry) => entry.id === selectedProvider);
|
||||
if (!input.selectedModel) {
|
||||
if (provider?.modelSelection.kind === "providerDefault") {
|
||||
return provider.modelSelection.label;
|
||||
}
|
||||
if (provider?.modelSelection.kind === "loading") {
|
||||
return "Loading...";
|
||||
}
|
||||
return input.isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return input.isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
if (provider.modelSelection.kind !== "models") {
|
||||
return provider.modelSelection.kind === "providerDefault"
|
||||
? provider.modelSelection.label
|
||||
: "Select model";
|
||||
}
|
||||
|
||||
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
|
||||
const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault);
|
||||
return (
|
||||
model?.modelLabel ??
|
||||
defaultModel?.modelLabel ??
|
||||
provider.modelSelection.rows[0]?.modelLabel ??
|
||||
"Select model"
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
export function matchesModelSearch(
|
||||
row: ProviderSelectionModelRow,
|
||||
normalizedQuery: string,
|
||||
): boolean {
|
||||
return scoreModelRow(row, normalizedQuery) !== null;
|
||||
}
|
||||
|
||||
function getModelRowSearchFields(row: ProviderSelectionModelRow): string[] {
|
||||
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
|
||||
}
|
||||
|
||||
export function scoreModelRow(row: ProviderSelectionModelRow, normalizedQuery: string) {
|
||||
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
|
||||
}
|
||||
|
||||
export function filterAndRankModelRows(
|
||||
rows: ProviderSelectionModelRow[],
|
||||
normalizedQuery: string,
|
||||
): ProviderSelectionModelRow[] {
|
||||
if (!normalizedQuery) return rows;
|
||||
const scored = rows
|
||||
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is { row: ProviderSelectionModelRow; score: NonNullable<typeof entry.score> } =>
|
||||
Boolean(entry.score),
|
||||
);
|
||||
|
||||
scored.sort((a, b) => {
|
||||
const cmp = compareMatchScores(a.score, b.score);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.row.modelLabel.localeCompare(b.row.modelLabel);
|
||||
});
|
||||
|
||||
return scored.map((entry) => entry.row);
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string {
|
||||
return selection.modelId.trim();
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerThinkingOptionId(
|
||||
selection: ProviderSelectionState,
|
||||
effectiveModelId: string,
|
||||
): string {
|
||||
const selectedThinkingOptionId = selection.thinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
selection.availableModels.find((model) => model.id === effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftCommandConfig(input: {
|
||||
selection: ProviderSelectionState;
|
||||
cwd: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.selection.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.selection.provider,
|
||||
cwd,
|
||||
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
|
||||
? { modeId: input.selection.modeId }
|
||||
: {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSubmissionReadiness(input: {
|
||||
text: string;
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
providerCount: number;
|
||||
selection: {
|
||||
provider: AgentProvider | string | null;
|
||||
modelId: string;
|
||||
availableModels: readonly unknown[];
|
||||
isModelLoading: boolean;
|
||||
};
|
||||
autoSubmitConfig: { provider: string; model: string | null } | null;
|
||||
workspaceDirectory: string | null;
|
||||
hasClient: boolean;
|
||||
}): ProviderSelectionReadiness {
|
||||
if (!input.allowsEmptyAutoSubmit && !input.text.trim()) {
|
||||
return { ok: false, reason: "Initial prompt is required" };
|
||||
}
|
||||
if (input.providerCount === 0) {
|
||||
return { ok: false, reason: "No available providers on the selected host" };
|
||||
}
|
||||
if (!(input.autoSubmitConfig?.provider ?? input.selection.provider)) {
|
||||
return { ok: false, reason: "Select a model" };
|
||||
}
|
||||
if (input.selection.isModelLoading) {
|
||||
return { ok: false, reason: "Model defaults are still loading" };
|
||||
}
|
||||
const hasSelectedModel = Boolean(input.autoSubmitConfig?.model ?? input.selection.modelId);
|
||||
if (!hasSelectedModel && input.selection.availableModels.length > 0) {
|
||||
return { ok: false, reason: "No model is available for the selected provider" };
|
||||
}
|
||||
if (!input.workspaceDirectory) {
|
||||
return { ok: false, reason: "Workspace directory not found" };
|
||||
}
|
||||
if (!input.hasClient) {
|
||||
return { ok: false, reason: "Host is not connected" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
mergeProviderPreferences,
|
||||
type FormPreferences,
|
||||
type ProviderPreferences,
|
||||
} from "./use-form-preferences";
|
||||
} from "@/hooks/use-form-preferences";
|
||||
|
||||
export interface FormInitialValues {
|
||||
serverId?: string | null;
|
||||
@@ -23,6 +23,7 @@ import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { SettingsGroup } from "@/screens/settings/settings-group";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -621,18 +622,13 @@ function ProjectConfigForm({
|
||||
testID="worktree-group"
|
||||
>
|
||||
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID="worktree-setup-input"
|
||||
accessibilityLabel="Worktree setup commands"
|
||||
multiline
|
||||
value={draft.setupText}
|
||||
onChangeText={handleSetupChange}
|
||||
placeholder="npm install"
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-setup-input"
|
||||
accessibilityLabel="Worktree setup commands"
|
||||
value={draft.setupText}
|
||||
onChangeText={handleSetupChange}
|
||||
placeholder="npm install"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
@@ -641,18 +637,13 @@ function ProjectConfigForm({
|
||||
trailing={teardownDocsLink}
|
||||
flush
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID="worktree-teardown-input"
|
||||
accessibilityLabel="Worktree teardown commands"
|
||||
multiline
|
||||
value={draft.teardownText}
|
||||
onChangeText={handleTeardownChange}
|
||||
placeholder="docker compose down"
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-teardown-input"
|
||||
accessibilityLabel="Worktree teardown commands"
|
||||
value={draft.teardownText}
|
||||
onChangeText={handleTeardownChange}
|
||||
placeholder="docker compose down"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -1008,18 +999,13 @@ function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPr
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID={meta.inputTestID}
|
||||
accessibilityLabel={meta.title}
|
||||
multiline
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
placeholder={meta.placeholder}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID={meta.inputTestID}
|
||||
accessibilityLabel={meta.title}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
placeholder={meta.placeholder}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1344,14 +1330,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
errorBlock: {
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
lifecycleInput: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
minHeight: 96,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
emptyScripts: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
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 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 { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
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 {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeIsConnected,
|
||||
useHostRuntimeSnapshot,
|
||||
useHostMutations,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { 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 { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
import { SettingsSection } from "@/screens/settings/settings-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 =
|
||||
"This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically.";
|
||||
@@ -68,7 +70,6 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
||||
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_HOST_HEADER: SheetHeader = { title: "Remove host" };
|
||||
|
||||
@@ -178,64 +179,23 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { renameHost } = useHostMutations();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftLabel, setDraftLabel] = useState(host.label ?? "");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftLabel(host.label ?? "");
|
||||
}, [host.serverId, host.label]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
const timeout = setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
return undefined;
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const nextLabel = draftLabel.trim();
|
||||
if (!nextLabel) {
|
||||
Alert.alert("Label required", "Enter a label for this host.");
|
||||
return;
|
||||
}
|
||||
if (isSaving) return;
|
||||
if (nextLabel === host.label.trim()) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const handleSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
const nextLabel = value.trim();
|
||||
if (nextLabel === host.label.trim()) return;
|
||||
await renameHost(host.serverId, nextLabel);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("[HostPage] Failed to rename host", error);
|
||||
Alert.alert("Error", "Unable to save host");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [draftLabel, host.label, host.serverId, isSaving, renameHost]);
|
||||
},
|
||||
[host.label, host.serverId, renameHost],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setDraftLabel(host.label ?? "");
|
||||
setIsEditing(false);
|
||||
}, [host.label, isSaving]);
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
setDraftLabel(host.label ?? "");
|
||||
setIsEditing(true);
|
||||
}, [host.label]);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
const openEditor = useCallback(() => setIsEditing(true), []);
|
||||
const closeEditor = useCallback(() => setIsEditing(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={handleStartEdit}
|
||||
onPress={openEditor}
|
||||
hitSlop={8}
|
||||
style={styles.identityEditButton}
|
||||
accessibilityRole="button"
|
||||
@@ -245,48 +205,16 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
<AdaptiveRenameModal
|
||||
visible={isEditing}
|
||||
onClose={handleCancel}
|
||||
header={RENAME_HOST_HEADER}
|
||||
title="Rename host"
|
||||
initialValue={host.label}
|
||||
placeholder="My Host"
|
||||
submitLabel="Save"
|
||||
onClose={closeEditor}
|
||||
onSubmit={handleSubmit}
|
||||
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>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -452,9 +380,9 @@ function ConnectionRow({
|
||||
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Operations">
|
||||
<RestartDaemonCard host={host} />
|
||||
<SettingsSection title="Daemon settings">
|
||||
<InjectPaseoToolsCard serverId={host.serverId} />
|
||||
<AppendSystemPromptCard serverId={host.serverId} />
|
||||
</SettingsSection>
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title="Pair devices">
|
||||
@@ -617,9 +545,9 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Inject Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowTitle}>Enable Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Automatically inject Paseo MCP tools into new agents
|
||||
Agents will be able to manage worktrees, agents and schedules
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
@@ -632,6 +560,111 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const persistedPrompt = config?.appendSystemPrompt ?? "";
|
||||
const [draft, setDraft] = useState(persistedPrompt);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: "Append system prompt" }), []);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(persistedPrompt);
|
||||
}, [persistedPrompt]);
|
||||
|
||||
const hasChanges = draft !== persistedPrompt;
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setDraft(persistedPrompt);
|
||||
setIsEditing(true);
|
||||
}, [persistedPrompt]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setDraft(persistedPrompt);
|
||||
setIsEditing(false);
|
||||
}, [isSaving, persistedPrompt]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
void patchConfig({ appendSystemPrompt: draft })
|
||||
.then(() => {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[HostPage] Failed to save append system prompt", error);
|
||||
})
|
||||
.finally(() => setIsSaving(false));
|
||||
}, [draft, patchConfig]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setDraft(persistedPrompt);
|
||||
}, [persistedPrompt]);
|
||||
|
||||
if (!isConnected) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.card} testID="host-page-append-system-prompt-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>System prompt</Text>
|
||||
<Text style={settingsStyles.rowHint}>Added a system prompt to all agents</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleOpen}
|
||||
testID="host-page-append-system-prompt-edit"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isEditing ? (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible
|
||||
onClose={handleClose}
|
||||
testID="host-page-append-system-prompt-sheet"
|
||||
desktopMaxWidth={560}
|
||||
>
|
||||
<SettingsTextAreaCard
|
||||
testID="host-page-append-system-prompt-input"
|
||||
accessibilityLabel="Append system prompt"
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Always keep replies concise."
|
||||
/>
|
||||
<View style={styles.appendPromptActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-reset"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-save"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PairDeviceRow() {
|
||||
const { theme } = useUnistyles();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -704,6 +737,8 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
||||
|
||||
return (
|
||||
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
|
||||
<RestartDaemonCard host={host} />
|
||||
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
@@ -828,6 +863,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
appendPromptActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
@@ -836,25 +876,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
renameBody: {
|
||||
gap: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
renameInput: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
color: theme.colors.foreground,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
renameActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
|
||||
const FLEX_1_STYLE = { flex: 1 };
|
||||
|
||||
@@ -202,7 +202,12 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
|
||||
};
|
||||
|
||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
||||
return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false };
|
||||
return {
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers,
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
function descendants(el: HTMLElement): HTMLElement[] {
|
||||
|
||||
@@ -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,
|
||||
Columns2,
|
||||
Copy,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Rows2,
|
||||
Globe,
|
||||
@@ -71,6 +72,7 @@ const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
@@ -101,6 +103,8 @@ function TabContextMenuItem({
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "copy-x":
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
case "pencil":
|
||||
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
||||
case "x":
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
default:
|
||||
@@ -150,6 +154,7 @@ interface WorkspaceDesktopTabsRowProps {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
@@ -467,6 +472,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -599,6 +605,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
@@ -630,6 +637,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCopyResumeCommand,
|
||||
onNavigateTab,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
setHoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
tabDropPreviewIndex,
|
||||
@@ -791,6 +799,7 @@ function ResolvedDesktopTabChip({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
@@ -815,6 +824,7 @@ function ResolvedDesktopTabChip({
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
@@ -838,6 +848,7 @@ function ResolvedDesktopTabChip({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
@@ -853,6 +864,7 @@ function ResolvedDesktopTabChip({
|
||||
onCopyAgentId,
|
||||
onCopyResumeCommand,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
tabCount,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
|
||||
|
||||
export interface WorkspaceDraftAutoSubmitConfig {
|
||||
provider: string;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export function validateDraftSubmission(input: {
|
||||
text: string;
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
composerState: {
|
||||
providerDefinitions: unknown[];
|
||||
selectedProvider: string | null;
|
||||
isModelLoading: boolean;
|
||||
effectiveModelId: string | null;
|
||||
availableModels: unknown[];
|
||||
};
|
||||
autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null;
|
||||
workspaceDirectory: string | null;
|
||||
hasClient: boolean;
|
||||
}): string | null {
|
||||
const {
|
||||
text,
|
||||
allowsEmptyAutoSubmit,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory,
|
||||
hasClient,
|
||||
} = input;
|
||||
const readiness = resolveSubmissionReadiness({
|
||||
text,
|
||||
allowsEmptyAutoSubmit,
|
||||
providerCount: composerState.providerDefinitions.length,
|
||||
selection: {
|
||||
provider: composerState.selectedProvider,
|
||||
modelId: composerState.effectiveModelId ?? "",
|
||||
availableModels: composerState.availableModels,
|
||||
isModelLoading: composerState.isModelLoading,
|
||||
},
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory,
|
||||
hasClient,
|
||||
});
|
||||
return readiness.ok ? null : (readiness.reason ?? null);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { validateDraftSubmission } from "./workspace-draft-agent-tab-core";
|
||||
|
||||
const baseComposerState = {
|
||||
providerDefinitions: [{ id: "deepseek-tui" }],
|
||||
selectedProvider: "deepseek-tui",
|
||||
isModelLoading: false,
|
||||
effectiveModelId: "",
|
||||
availableModels: [],
|
||||
};
|
||||
|
||||
function validate(overrides = {}) {
|
||||
return validateDraftSubmission({
|
||||
text: "hello",
|
||||
allowsEmptyAutoSubmit: false,
|
||||
composerState: baseComposerState,
|
||||
autoSubmitConfig: null,
|
||||
workspaceDirectory: "/tmp/project",
|
||||
hasClient: true,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("workspace draft agent model validation", () => {
|
||||
test("allows a ready provider with no models to submit without a selected model", () => {
|
||||
expect(validate({})).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps waiting while model defaults are loading", () => {
|
||||
expect(
|
||||
validate({
|
||||
composerState: {
|
||||
...baseComposerState,
|
||||
isModelLoading: true,
|
||||
},
|
||||
}),
|
||||
).toBe("Model defaults are still loading");
|
||||
});
|
||||
|
||||
test("still requires a selected model when the provider exposes models", () => {
|
||||
expect(
|
||||
validate({
|
||||
composerState: {
|
||||
...baseComposerState,
|
||||
availableModels: [{ id: "deepseek/deepseek-v4-pro" }],
|
||||
},
|
||||
}),
|
||||
).toBe("No model is available for the selected provider");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import { validateDraftSubmission } from "@/screens/workspace/workspace-draft-agent-tab-core";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
@@ -73,51 +74,6 @@ function resolveAutoSubmitConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function validateDraftSubmission(input: {
|
||||
text: string;
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
composerState: {
|
||||
providerDefinitions: unknown[];
|
||||
selectedProvider: string | null;
|
||||
isModelLoading: boolean;
|
||||
effectiveModelId: string | null;
|
||||
};
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
workspaceDirectory: string | null;
|
||||
hasClient: boolean;
|
||||
}): string | null {
|
||||
const {
|
||||
text,
|
||||
allowsEmptyAutoSubmit,
|
||||
composerState,
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory,
|
||||
hasClient,
|
||||
} = input;
|
||||
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
||||
return "Initial prompt is required";
|
||||
}
|
||||
if (composerState.providerDefinitions.length === 0) {
|
||||
return "No available providers on the selected host";
|
||||
}
|
||||
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
||||
return "Select a model";
|
||||
}
|
||||
if (composerState.isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
if (!(autoSubmitConfig?.model ?? composerState.effectiveModelId)) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (!workspaceDirectory) {
|
||||
return "Workspace directory not found";
|
||||
}
|
||||
if (!hasClient) {
|
||||
return "Host is not connected";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDraftModeIdOverride(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
modeOptionsCount: number;
|
||||
@@ -323,6 +279,16 @@ interface WorkspaceDraftAgentTabProps {
|
||||
onOpenImportSheet?: () => void;
|
||||
}
|
||||
|
||||
function resolveImportPillPress(
|
||||
onOpenImportSheet: (() => void) | undefined,
|
||||
isSubmitting: boolean,
|
||||
): (() => void) | null {
|
||||
if (isSubmitting) {
|
||||
return null;
|
||||
}
|
||||
return onOpenImportSheet ?? null;
|
||||
}
|
||||
|
||||
export function WorkspaceDraftAgentTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -588,6 +554,7 @@ export function WorkspaceDraftAgentTab({
|
||||
const handleDropdownCloseFocus = useCallback(() => {
|
||||
focusInputRef.current?.();
|
||||
}, []);
|
||||
const importPillPress = resolveImportPillPress(onOpenImportSheet, isSubmitting);
|
||||
const composerStatusControls = useMemo(
|
||||
() => ({
|
||||
...composerState.statusControls,
|
||||
@@ -645,10 +612,10 @@ export function WorkspaceDraftAgentTab({
|
||||
</View>
|
||||
|
||||
<View style={inputAreaWrapperStyle}>
|
||||
{onOpenImportSheet ? (
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={onOpenImportSheet} disabled={isSubmitting} />
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactElement, useCallback, useEffect, useMemo } from "react";
|
||||
import { type ReactElement, useCallback, useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -178,13 +178,6 @@ export function WorkspaceOpenInEditorButton({
|
||||
);
|
||||
const primaryOption = targets.find((target) => target.id === effectivePreferredEditorId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
|
||||
return;
|
||||
}
|
||||
void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined);
|
||||
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
|
||||
|
||||
const openMutation = useMutation({
|
||||
mutationFn: (target: OpenTarget) => Promise.resolve(target.onOpen()),
|
||||
onError: (error: unknown) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useIsFocused } from "@react-navigation/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 * as Clipboard from "expo-clipboard";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Globe,
|
||||
Import as ImportIcon,
|
||||
PanelRight,
|
||||
Pencil,
|
||||
RotateCw,
|
||||
Settings,
|
||||
SquarePen,
|
||||
@@ -109,6 +110,10 @@ import {
|
||||
WorkspaceTabOptionRow,
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import {
|
||||
useWorkspaceTabRename,
|
||||
WorkspaceTabRenameModal,
|
||||
} from "@/screens/workspace/use-workspace-tab-rename";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
@@ -181,6 +186,7 @@ const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine);
|
||||
const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine);
|
||||
const ThemedCopyX = withUnistyles(CopyX);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
@@ -297,6 +303,7 @@ interface MobileWorkspaceTabSwitcherProps {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
@@ -436,6 +443,8 @@ function MobileTabDropdownMenuItem({
|
||||
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
|
||||
case "copy-x":
|
||||
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
|
||||
case "pencil":
|
||||
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
|
||||
case "x":
|
||||
return <ThemedX size={16} uniProps={mutedColorMapping} />;
|
||||
default:
|
||||
@@ -473,6 +482,7 @@ function MobileWorkspaceTabOption({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
@@ -489,6 +499,7 @@ function MobileWorkspaceTabOption({
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
@@ -504,6 +515,7 @@ function MobileWorkspaceTabOption({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsAbove,
|
||||
onCloseTabsAfter: onCloseTabsBelow,
|
||||
@@ -558,6 +570,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
@@ -611,6 +624,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onCopyResumeCommand={onCopyResumeCommand}
|
||||
onCopyAgentId={onCopyAgentId}
|
||||
onReloadAgent={onReloadAgent}
|
||||
onRenameTab={onRenameTab}
|
||||
onCloseTab={onCloseTab}
|
||||
onCloseTabsAbove={onCloseTabsAbove}
|
||||
onCloseTabsBelow={onCloseTabsBelow}
|
||||
@@ -627,6 +641,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
@@ -1532,6 +1547,7 @@ function WorkspaceScreenContent({
|
||||
openWorkspaceTabFocused,
|
||||
toast,
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
createMutation: createTerminalMutation,
|
||||
createTerminal,
|
||||
@@ -1543,6 +1559,7 @@ function WorkspaceScreenContent({
|
||||
liveTerminalIds,
|
||||
pendingCreateInput: pendingTerminalCreateInput,
|
||||
query: terminalsQuery,
|
||||
queryKey: terminalsQueryKey,
|
||||
removeTerminalFromCache,
|
||||
standaloneTerminalIds,
|
||||
terminals,
|
||||
@@ -2125,6 +2142,14 @@ function WorkspaceScreenContent({
|
||||
|
||||
const [_hoveredTabKey, setHoveredTabKey] = 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 map = new Map<string, WorkspaceTabDescriptor>();
|
||||
@@ -3152,6 +3177,7 @@ function WorkspaceScreenContent({
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
||||
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||
@@ -3186,6 +3212,7 @@ function WorkspaceScreenContent({
|
||||
handleCopyResumeCommand,
|
||||
handleCopyAgentId,
|
||||
handleReloadAgent,
|
||||
handleRenameTab,
|
||||
handleCloseTabsToLeftInPane,
|
||||
handleCloseTabsToRightInPane,
|
||||
handleCloseOtherTabsInPane,
|
||||
@@ -3268,6 +3295,7 @@ function WorkspaceScreenContent({
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
@@ -3289,6 +3317,7 @@ function WorkspaceScreenContent({
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onReloadAgent={handleReloadAgent}
|
||||
onRenameTab={handleRenameTab}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
@@ -3337,6 +3366,11 @@ function WorkspaceScreenContent({
|
||||
onClose={closeImportSheet}
|
||||
onImportedAgent={handleImportedAgent}
|
||||
/>
|
||||
<WorkspaceTabRenameModal
|
||||
renamingTab={renamingTab}
|
||||
onSubmit={handleRenameModalSubmit}
|
||||
onClose={handleRenameModalClose}
|
||||
/>
|
||||
</View>
|
||||
</WorkspaceFocusProvider>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
const onCopyResumeCommand = vi.fn();
|
||||
const onCopyAgentId = vi.fn();
|
||||
const onReloadAgent = vi.fn();
|
||||
const onRenameTab = vi.fn();
|
||||
const onCloseTab = vi.fn();
|
||||
const onCloseTabsBefore = vi.fn();
|
||||
const onCloseTabsAfter = vi.fn();
|
||||
@@ -30,6 +31,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
@@ -39,6 +41,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Rename",
|
||||
"Close to the left",
|
||||
"Close to the right",
|
||||
"Close other tabs",
|
||||
@@ -57,6 +60,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
@@ -66,6 +70,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Rename",
|
||||
"Close tabs above",
|
||||
"Close tabs below",
|
||||
"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({
|
||||
surface: "mobile",
|
||||
tab: {
|
||||
@@ -89,6 +94,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
@@ -101,6 +107,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Rename")).toBe(false);
|
||||
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -114,6 +121,7 @@ describe("buildWorkspaceTabMenuEntries", () => {
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onReloadAgent: vi.fn(),
|
||||
onRenameTab: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: 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";
|
||||
key: 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;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
@@ -30,6 +37,7 @@ interface BuildWorkspaceTabMenuEntriesInput {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
|
||||
@@ -43,6 +51,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onReloadAgent: (agentId: string) => Promise<void> | void;
|
||||
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
@@ -102,6 +111,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
@@ -135,9 +145,22 @@ export function buildWorkspaceTabMenuEntries(
|
||||
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({
|
||||
kind: "separator",
|
||||
key: "copy-separator",
|
||||
key: "rename-separator",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,6 +240,7 @@ export function buildWorkspaceDesktopTabActions(
|
||||
onCopyResumeCommand: input.onCopyResumeCommand,
|
||||
onCopyAgentId: input.onCopyAgentId,
|
||||
onReloadAgent: input.onReloadAgent,
|
||||
onRenameTab: input.onRenameTab,
|
||||
onCloseTab: input.onCloseTab,
|
||||
onCloseTabsBefore: input.onCloseTabsToLeft,
|
||||
onCloseTabsAfter: input.onCloseTabsToRight,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createLastWorkspaceSelectionStore,
|
||||
type ActiveWorkspaceSelection,
|
||||
type LastWorkspaceSelectionStorage,
|
||||
} from "./last-workspace-selection";
|
||||
|
||||
class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage {
|
||||
private finishRead: (value: string | null) => void = () => {};
|
||||
private readonly pendingRead = new Promise<string | null>((resolve) => {
|
||||
this.finishRead = resolve;
|
||||
});
|
||||
private saved: string | null = null;
|
||||
|
||||
read(): Promise<string | null> {
|
||||
return this.pendingRead;
|
||||
}
|
||||
|
||||
async write(value: string): Promise<void> {
|
||||
this.saved = value;
|
||||
}
|
||||
|
||||
finishHydrationWith(selection: ActiveWorkspaceSelection | null) {
|
||||
this.finishRead(selection ? JSON.stringify(selection) : null);
|
||||
}
|
||||
|
||||
getSavedSelection(): ActiveWorkspaceSelection | null {
|
||||
return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("last workspace selection", () => {
|
||||
it("hydrates the saved workspace selection", async () => {
|
||||
const storage = new DelayedWorkspaceSelectionStorage();
|
||||
const store = createLastWorkspaceSelectionStore(storage);
|
||||
const hydration = store.hydrate();
|
||||
|
||||
storage.finishHydrationWith({ serverId: "server-saved", workspaceId: "workspace-saved" });
|
||||
await hydration;
|
||||
|
||||
expect(store.getSelection()).toEqual({
|
||||
serverId: "server-saved",
|
||||
workspaceId: "workspace-saved",
|
||||
});
|
||||
expect(store.isHydrated()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a newer workspace selection when storage hydration finishes late", async () => {
|
||||
const storage = new DelayedWorkspaceSelectionStorage();
|
||||
const store = createLastWorkspaceSelectionStore(storage);
|
||||
const hydration = store.hydrate();
|
||||
|
||||
store.remember({ serverId: "server-new", workspaceId: "workspace-new" });
|
||||
storage.finishHydrationWith({ serverId: "server-old", workspaceId: "workspace-old" });
|
||||
await hydration;
|
||||
|
||||
expect(store.getSelection()).toEqual({
|
||||
serverId: "server-new",
|
||||
workspaceId: "workspace-new",
|
||||
});
|
||||
expect(storage.getSavedSelection()).toEqual({
|
||||
serverId: "server-new",
|
||||
workspaceId: "workspace-new",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface LastWorkspaceSelectionStorage {
|
||||
read(): Promise<string | null>;
|
||||
write(value: string): Promise<void>;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return null;
|
||||
}
|
||||
const record = input as Record<string, unknown>;
|
||||
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
|
||||
const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : "";
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeWorkspaceSelection(JSON.parse(stored));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectionStorage) {
|
||||
let selection: ActiveWorkspaceSelection | null = null;
|
||||
let hydrated = false;
|
||||
let hydrationPromise: Promise<void> | null = null;
|
||||
let revision = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notifyListeners() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function remember(next: ActiveWorkspaceSelection) {
|
||||
const normalized = normalizeWorkspaceSelection(next);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selection?.serverId === normalized.serverId &&
|
||||
selection.workspaceId === normalized.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
selection = normalized;
|
||||
revision += 1;
|
||||
notifyListeners();
|
||||
void storage.write(JSON.stringify(normalized)).catch(() => {});
|
||||
}
|
||||
|
||||
function hydrate(): Promise<void> {
|
||||
if (hydrationPromise) {
|
||||
return hydrationPromise;
|
||||
}
|
||||
const hydrationRevision = revision;
|
||||
hydrationPromise = storage
|
||||
.read()
|
||||
.then((stored) => {
|
||||
if (revision === hydrationRevision) {
|
||||
selection = parseStoredWorkspaceSelection(stored);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
if (revision === hydrationRevision) {
|
||||
selection = null;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
hydrated = true;
|
||||
notifyListeners();
|
||||
});
|
||||
return hydrationPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
getSelection: () => selection,
|
||||
hydrate,
|
||||
isHydrated: () => hydrated,
|
||||
remember,
|
||||
subscribe: (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,10 @@ const pathnameState = vi.hoisted(() => ({
|
||||
const localParamsState = vi.hoisted(() => ({
|
||||
value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
|
||||
}));
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(async () => null as string | null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: routerMock,
|
||||
@@ -20,6 +24,10 @@ vi.mock("expo-router", () => ({
|
||||
usePathname: () => pathnameState.value,
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
navigateToLastWorkspace,
|
||||
navigateToWorkspace,
|
||||
@@ -29,6 +37,7 @@ import {
|
||||
describe("workspace navigation", () => {
|
||||
beforeEach(() => {
|
||||
routerMock.dismissTo.mockReset();
|
||||
asyncStorageMock.setItem.mockClear();
|
||||
pathnameState.value = "/";
|
||||
localParamsState.value = {};
|
||||
});
|
||||
@@ -41,6 +50,10 @@ describe("workspace navigation", () => {
|
||||
navigateToWorkspace("server-1", "workspace-a");
|
||||
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"paseo:last-workspace-route-selection",
|
||||
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the active workspace from the current route", () => {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import {
|
||||
createLastWorkspaceSelectionStore,
|
||||
type ActiveWorkspaceSelection,
|
||||
type LastWorkspaceSelectionStorage,
|
||||
} from "@/stores/last-workspace-selection";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { pickAttentionAgent } from "@/utils/agent-attention";
|
||||
@@ -12,16 +19,34 @@ import {
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
|
||||
export interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
interface NavigateToWorkspaceOptions {
|
||||
currentPathname?: string | null;
|
||||
}
|
||||
|
||||
let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null;
|
||||
const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||
|
||||
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
|
||||
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
|
||||
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
|
||||
};
|
||||
|
||||
const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
|
||||
lastWorkspaceSelectionStorage,
|
||||
);
|
||||
|
||||
export function hydrateLastWorkspaceSelection(): Promise<void> {
|
||||
return lastWorkspaceSelectionStore.hydrate();
|
||||
}
|
||||
|
||||
export function getLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return lastWorkspaceSelectionStore.getSelection();
|
||||
}
|
||||
|
||||
export function getIsLastWorkspaceSelectionHydrated(): boolean {
|
||||
return lastWorkspaceSelectionStore.isHydrated();
|
||||
}
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
@@ -74,16 +99,17 @@ export function navigateToWorkspace(
|
||||
});
|
||||
}
|
||||
|
||||
lastWorkspaceSelection = { serverId, workspaceId };
|
||||
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
|
||||
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
|
||||
router.dismissTo(route);
|
||||
}
|
||||
|
||||
export function navigateToLastWorkspace(): boolean {
|
||||
if (!lastWorkspaceSelection) {
|
||||
const selection = lastWorkspaceSelectionStore.getSelection();
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
|
||||
navigateToWorkspace(selection.serverId, selection.workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -95,8 +121,31 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
const selection =
|
||||
parseHostWorkspaceRouteFromPathname(usePathname()) ??
|
||||
parseWorkspaceSelectionFromRouteParams(params);
|
||||
if (selection) {
|
||||
lastWorkspaceSelection = selection;
|
||||
}
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
useEffect(() => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
|
||||
}, [serverId, workspaceId]);
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function useLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return useSyncExternalStore(
|
||||
lastWorkspaceSelectionStore.subscribe,
|
||||
getLastWorkspaceSelection,
|
||||
getLastWorkspaceSelection,
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsLastWorkspaceSelectionHydrated(): boolean {
|
||||
return useSyncExternalStore(
|
||||
lastWorkspaceSelectionStore.subscribe,
|
||||
getIsLastWorkspaceSelectionHydrated,
|
||||
getIsLastWorkspaceSelectionHydrated,
|
||||
);
|
||||
}
|
||||
|
||||
void hydrateLastWorkspaceSelection();
|
||||
|
||||
@@ -235,6 +235,7 @@ interface DarkThemeConfig {
|
||||
borderAccent: string;
|
||||
accent: string;
|
||||
accentBright: string;
|
||||
accentForeground?: string;
|
||||
destructive: string;
|
||||
}
|
||||
|
||||
@@ -277,7 +278,7 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
||||
|
||||
accent: tint.accent,
|
||||
accentBright: tint.accentBright,
|
||||
accentForeground: "#ffffff",
|
||||
accentForeground: tint.accentForeground ?? "#ffffff",
|
||||
|
||||
destructive: tint.destructive,
|
||||
destructiveForeground: "#ffffff",
|
||||
@@ -352,8 +353,9 @@ const zincDarkColors = buildDarkSemanticColors({
|
||||
scrollbarHandle: "#71717a",
|
||||
border: "#27272a",
|
||||
borderAccent: "#303036",
|
||||
accent: "#20744A",
|
||||
accentBright: "#7ccba0",
|
||||
accent: "#e4e4e7",
|
||||
accentBright: "#fafafa",
|
||||
accentForeground: "#18181b", // monochrome zinc accent is near-white — needs dark text
|
||||
destructive: "#c44a4a", // neutral red, hue 0 — clearly red without screaming
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@server/shared/terminal-input-mode";
|
||||
import {
|
||||
type PendingTerminalModifiers,
|
||||
isAppleHandheldPlatform,
|
||||
isTerminalModifierDomKey,
|
||||
mergeTerminalModifiers,
|
||||
normalizeDomTerminalKey,
|
||||
@@ -84,6 +85,14 @@ const isMac =
|
||||
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
||||
/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 FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
@@ -365,6 +374,7 @@ export class TerminalEmulatorRuntime {
|
||||
metaKey: event.metaKey,
|
||||
pendingModifiers: this.pendingModifiers,
|
||||
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
||||
isAppleHandheld,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasPendingTerminalModifiers,
|
||||
isAppleHandheldPlatform,
|
||||
isTerminalModifierDomKey,
|
||||
mapTerminalDataToKey,
|
||||
mergeTerminalModifiers,
|
||||
@@ -10,6 +11,13 @@ import {
|
||||
shouldInterceptDomTerminalKey,
|
||||
} 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", () => {
|
||||
it("normalizes supported DOM keys", () => {
|
||||
expect(normalizeDomTerminalKey("Esc")).toBe("Escape");
|
||||
@@ -157,6 +165,140 @@ describe("terminal key helpers", () => {
|
||||
).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", () => {
|
||||
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
|
||||
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;
|
||||
}
|
||||
|
||||
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: {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
@@ -90,6 +111,7 @@ export function shouldInterceptDomTerminalKey(args: {
|
||||
metaKey: boolean;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
enhancedInputActive?: boolean;
|
||||
isAppleHandheld?: boolean;
|
||||
}): boolean {
|
||||
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
||||
return true;
|
||||
@@ -97,6 +119,19 @@ export function shouldInterceptDomTerminalKey(args: {
|
||||
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:e2e:lifecycle": "npx tsx tests/e2e/agent-lifecycle.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.78",
|
||||
"chalk": "^5.3.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Command, Option } from "commander";
|
||||
import { createAgentCommand } from "./commands/agent/index.js";
|
||||
import { createAcpCommand } from "./commands/acp/index.js";
|
||||
import { createDaemonCommand } from "./commands/daemon/index.js";
|
||||
import { createChatCommand } from "./commands/chat/index.js";
|
||||
import { createLoopCommand } from "./commands/loop/index.js";
|
||||
@@ -94,6 +95,7 @@ export function createCli(): Command {
|
||||
);
|
||||
|
||||
// Top-level local daemon shortcuts
|
||||
program.addCommand(createAcpCommand());
|
||||
program.addCommand(onboardCommand());
|
||||
program.addCommand(daemonStartCommand());
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import {
|
||||
AgentSideConnection,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
type Agent,
|
||||
type ContentBlock,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
type LoadSessionRequest,
|
||||
type LoadSessionResponse,
|
||||
type NewSessionRequest,
|
||||
type NewSessionResponse,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type ResumeSessionRequest,
|
||||
type ResumeSessionResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
|
||||
interface AntigravitySession {
|
||||
cwd: string;
|
||||
hasPrompted: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL_ID = "default";
|
||||
const DEFAULT_MODE_ID = "default";
|
||||
|
||||
function promptToText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return block.text;
|
||||
}
|
||||
if (block.type === "resource_link") {
|
||||
return block.uri;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function runAgyPrompt(input: {
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
continueConversation: boolean;
|
||||
signal: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const args = [
|
||||
...(input.continueConversation ? ["--continue"] : []),
|
||||
"--print",
|
||||
input.prompt,
|
||||
"--print-timeout",
|
||||
"5m",
|
||||
];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("agy", args, {
|
||||
cwd: input.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
AGY_CLI_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const abort = () => {
|
||||
child.kill("SIGTERM");
|
||||
};
|
||||
input.signal.addEventListener("abort", abort, { once: true });
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
input.signal.removeEventListener("abort", abort);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
input.signal.removeEventListener("abort", abort);
|
||||
if (input.signal.aborted) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
return;
|
||||
}
|
||||
const details = stderr.trim() || stdout.trim() || `agy exited with ${signal ?? code}`;
|
||||
reject(new Error(details));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class AntigravityACPAgent implements Agent {
|
||||
private readonly sessions = new Map<string, AntigravitySession>();
|
||||
private readonly pendingPrompts = new Map<string, AbortController>();
|
||||
|
||||
constructor(private readonly connection: AgentSideConnection) {}
|
||||
|
||||
async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
|
||||
return {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentInfo: {
|
||||
name: "Paseo Antigravity ACP Adapter",
|
||||
version: "1.0.0",
|
||||
},
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
sessionCapabilities: {
|
||||
resume: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
this.sessions.set(sessionId, {
|
||||
cwd: params.cwd,
|
||||
hasPrompted: false,
|
||||
});
|
||||
return this.buildSessionResponse(sessionId);
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
this.sessions.set(params.sessionId, {
|
||||
cwd: params.cwd,
|
||||
hasPrompted: true,
|
||||
});
|
||||
return this.buildSessionState();
|
||||
}
|
||||
|
||||
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
this.sessions.set(params.sessionId, {
|
||||
cwd: params.cwd,
|
||||
hasPrompted: true,
|
||||
});
|
||||
return this.buildSessionState();
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Unknown Antigravity session: ${params.sessionId}`);
|
||||
}
|
||||
|
||||
const prompt = promptToText(params.prompt);
|
||||
const abortController = new AbortController();
|
||||
this.pendingPrompts.set(params.sessionId, abortController);
|
||||
|
||||
try {
|
||||
const text = await runAgyPrompt({
|
||||
prompt,
|
||||
cwd: session.cwd,
|
||||
continueConversation: session.hasPrompted,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
session.hasPrompted = true;
|
||||
if (abortController.signal.aborted) {
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
|
||||
if (text.length > 0) {
|
||||
await this.connection.sessionUpdate({
|
||||
sessionId: params.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text },
|
||||
messageId: randomUUID(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return { stopReason: "end_turn" };
|
||||
} finally {
|
||||
this.pendingPrompts.delete(params.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(): Promise<Record<string, never>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async cancel(params: { sessionId: string }): Promise<void> {
|
||||
this.pendingPrompts.get(params.sessionId)?.abort();
|
||||
}
|
||||
|
||||
private buildSessionResponse(sessionId: string): NewSessionResponse {
|
||||
return {
|
||||
sessionId,
|
||||
...this.buildSessionState(),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSessionState(): Omit<NewSessionResponse, "sessionId"> {
|
||||
return {
|
||||
models: {
|
||||
currentModelId: DEFAULT_MODEL_ID,
|
||||
availableModels: [
|
||||
{
|
||||
modelId: DEFAULT_MODEL_ID,
|
||||
name: "Configured Antigravity model",
|
||||
description: "Uses the model currently selected in Antigravity CLI.",
|
||||
},
|
||||
],
|
||||
},
|
||||
modes: {
|
||||
currentModeId: DEFAULT_MODE_ID,
|
||||
availableModes: [
|
||||
{
|
||||
id: DEFAULT_MODE_ID,
|
||||
name: "Default",
|
||||
description: "Use Antigravity CLI's configured permissions and settings.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function runAntigravityAcpAdapter(): void {
|
||||
const input = Writable.toWeb(process.stdout);
|
||||
const output = Readable.toWeb(process.stdin);
|
||||
const stream = ndJsonStream(input, output);
|
||||
const connection = new AgentSideConnection(
|
||||
(agentConnection) => new AntigravityACPAgent(agentConnection),
|
||||
stream,
|
||||
);
|
||||
void connection;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Command } from "commander";
|
||||
import { runAntigravityAcpAdapter } from "./antigravity.js";
|
||||
|
||||
export function createAcpCommand(): Command {
|
||||
const command = new Command("acp").description("Run local ACP adapter commands");
|
||||
|
||||
command
|
||||
.command("antigravity")
|
||||
.description("Run an ACP adapter for the installed agy CLI")
|
||||
.action(() => {
|
||||
runAntigravityAcpAdapter();
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveImportCwd } from "./import.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveImportCwd, runImportCommand } from "./import.js";
|
||||
|
||||
const importAgent = vi.fn();
|
||||
const close = vi.fn();
|
||||
|
||||
vi.mock("../../utils/client.js", () => ({
|
||||
connectToDaemon: vi.fn(async () => ({
|
||||
importAgent,
|
||||
close,
|
||||
})),
|
||||
getDaemonHost: vi.fn(() => "ws://127.0.0.1:6767"),
|
||||
}));
|
||||
|
||||
describe("resolveImportCwd", () => {
|
||||
it("uses the invoking process cwd when --cwd is omitted", () => {
|
||||
@@ -19,4 +30,30 @@ describe("resolveImportCwd", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts pi as an import provider", async () => {
|
||||
importAgent.mockResolvedValueOnce({
|
||||
id: "agent-1",
|
||||
status: "idle",
|
||||
provider: "pi",
|
||||
cwd: "/tmp/project",
|
||||
title: "Imported Pi session",
|
||||
});
|
||||
|
||||
const result = await runImportCommand(
|
||||
"pi-session-1",
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
cwd: "/tmp/project",
|
||||
});
|
||||
expect(result.data.provider).toBe("pi");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,13 +5,15 @@ import type { CommandError, CommandOptions, SingleResult } from "../../output/in
|
||||
import { agentRunSchema, type AgentRunResult } from "./run.js";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/server";
|
||||
|
||||
const IMPORT_PROVIDERS = new Set(["claude", "codex", "opencode", "acp"]);
|
||||
const IMPORT_PROVIDER_LIST = ["claude", "codex", "opencode", "pi", "acp"] as const;
|
||||
const IMPORT_PROVIDERS = new Set<string>(IMPORT_PROVIDER_LIST);
|
||||
const IMPORT_PROVIDER_HELP = IMPORT_PROVIDER_LIST.join(", ");
|
||||
|
||||
export function addImportOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description("Import an existing provider session as a Paseo agent")
|
||||
.argument("<id>", "Provider session/thread ID to import")
|
||||
.requiredOption("--provider <provider>", "Agent provider: claude, codex, opencode, or acp")
|
||||
.requiredOption("--provider <provider>", `Agent provider: ${IMPORT_PROVIDER_HELP}`)
|
||||
.option("--cwd <path>", "Working directory for providers that require it")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
@@ -54,7 +56,7 @@ function parseImportProvider(provider: string | undefined): string {
|
||||
throw {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: `Unsupported provider: ${normalizedProvider}`,
|
||||
details: "Supported providers: claude, codex, opencode, acp",
|
||||
details: `Supported providers: ${IMPORT_PROVIDER_HELP}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
|
||||
@@ -392,9 +392,15 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...envWithHome(options.home),
|
||||
// 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_HOSTNAMES: 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 config = loadConfig(home, { env });
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Command } from "commander";
|
||||
import chalk from "chalk";
|
||||
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";
|
||||
|
||||
interface PairOptions {
|
||||
@@ -22,6 +24,35 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
}
|
||||
|
||||
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 pairing = await generateLocalPairingOffer({
|
||||
paseoHome,
|
||||
@@ -34,6 +65,13 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
|
||||
includeQr: true,
|
||||
});
|
||||
|
||||
outputPairingResult(pairing, options);
|
||||
}
|
||||
|
||||
function outputPairingResult(
|
||||
pairing: { relayEnabled: boolean; url: string | null; qr: string | null },
|
||||
options: PairOptions,
|
||||
): void {
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
console.error(chalk.red("Relay pairing is disabled for this daemon config."));
|
||||
console.error(chalk.yellow("Enable relay and run this command again."));
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ProviderBinaryStatus {
|
||||
label: string;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
source?: "daemon" | "local";
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -99,7 +100,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
|
||||
return "red";
|
||||
}
|
||||
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";
|
||||
return "green";
|
||||
}
|
||||
@@ -149,7 +150,13 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
rows.push({ key: "", value: "" });
|
||||
rows.push({ key: "Providers", value: "" });
|
||||
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" });
|
||||
} else if (!provider.version) {
|
||||
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
|
||||
@@ -210,6 +217,7 @@ interface DaemonProbeResult {
|
||||
runningAgents?: number;
|
||||
idleAgents?: number;
|
||||
daemonNodeOverride?: string;
|
||||
daemonProviders?: ProviderBinaryStatus[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -231,12 +239,32 @@ async function probeDaemonOverWebsocket(args: {
|
||||
}
|
||||
|
||||
const daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
|
||||
const supportsDaemonStatusRpc =
|
||||
client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true;
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
const runningAgents = agents.filter((a) => a.status === "running").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) {
|
||||
return {
|
||||
connectedDaemon: "reachable",
|
||||
@@ -244,6 +272,7 @@ async function probeDaemonOverWebsocket(args: {
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
daemonNodeOverride: "unknown (API reachable, PID unresolved)",
|
||||
daemonProviders,
|
||||
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} but no local daemon PID file was found`,
|
||||
@@ -255,6 +284,7 @@ async function probeDaemonOverWebsocket(args: {
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
daemonProviders,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@@ -278,6 +308,7 @@ interface ProbeMergeState {
|
||||
daemonVersion: string | null;
|
||||
runningAgents: number | null;
|
||||
idleAgents: number | null;
|
||||
daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||
note: string | undefined;
|
||||
}
|
||||
|
||||
@@ -290,6 +321,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "prob
|
||||
daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion,
|
||||
runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents,
|
||||
idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents,
|
||||
daemonProviders: probe.daemonProviders ?? input.daemonProviders,
|
||||
note: probe.note ? appendNote(input.note, probe.note) : input.note,
|
||||
};
|
||||
}
|
||||
@@ -338,6 +370,7 @@ export async function runStatusCommand(
|
||||
let runningAgents: number | null = null;
|
||||
let idleAgents: number | null = null;
|
||||
let daemonVersion: string | null = null;
|
||||
let daemonProviders: ProviderBinaryStatus[] | undefined;
|
||||
let note: string | undefined;
|
||||
|
||||
if (!state.running && state.stalePidFile && state.pidInfo) {
|
||||
@@ -347,17 +380,26 @@ export async function runStatusCommand(
|
||||
|
||||
if (host) {
|
||||
const probe = await probeDaemonOverWebsocket({ host, state });
|
||||
({ connectedDaemon, localDaemon, daemonNode, daemonVersion, runningAgents, idleAgents, note } =
|
||||
applyProbeToStatus({
|
||||
probe,
|
||||
connectedDaemon,
|
||||
localDaemon,
|
||||
daemonNode,
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
note,
|
||||
}));
|
||||
({
|
||||
connectedDaemon,
|
||||
localDaemon,
|
||||
daemonNode,
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
daemonProviders,
|
||||
note,
|
||||
} = applyProbeToStatus({
|
||||
probe,
|
||||
connectedDaemon,
|
||||
localDaemon,
|
||||
daemonNode,
|
||||
daemonVersion,
|
||||
runningAgents,
|
||||
idleAgents,
|
||||
daemonProviders,
|
||||
note,
|
||||
}));
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
const providers = await checkProviderBinaries();
|
||||
const providers = daemonProviders ?? (await checkProviderBinaries());
|
||||
|
||||
const daemonStatus: DaemonStatus = {
|
||||
serverId,
|
||||
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
getMainWindowChromeOptions,
|
||||
getWindowBackgroundColor,
|
||||
resolveSystemWindowTheme,
|
||||
setupDarwinPaintRefresh,
|
||||
setupWindowResizeEvents,
|
||||
setupDefaultContextMenu,
|
||||
setupDragDropPrevention,
|
||||
buildStandardContextMenuItems,
|
||||
} from "./window/window-manager.js";
|
||||
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
|
||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||
import {
|
||||
registerNotificationHandlers,
|
||||
@@ -398,7 +398,7 @@ async function createMainWindow(): Promise<void> {
|
||||
app.dock?.setBadge(devWorktreeName);
|
||||
}
|
||||
|
||||
setupDarwinPaintRefresh(mainWindow);
|
||||
setupDarwinCompositorWatchdog(mainWindow);
|
||||
setupWindowResizeEvents(mainWindow);
|
||||
setupDefaultContextMenu(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(
|
||||
contents: WebContents,
|
||||
params: Electron.ContextMenuParams,
|
||||
|
||||
@@ -35,6 +35,10 @@ function createMockTransportPair(): [Transport, Transport] {
|
||||
return [transportA, transportB];
|
||||
}
|
||||
|
||||
async function waitForAsyncDelivery(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
describe("EncryptedChannel", () => {
|
||||
it("establishes encrypted channel between daemon and client", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
@@ -96,7 +100,7 @@ describe("EncryptedChannel", () => {
|
||||
await clientChannel.send("Second message from client");
|
||||
|
||||
// Wait for async delivery
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitForAsyncDelivery();
|
||||
|
||||
expect(daemonMessages).toEqual(["Hello from client", "Second message from client"]);
|
||||
expect(clientMessages).toEqual(["Hello from daemon"]);
|
||||
@@ -194,4 +198,75 @@ describe("EncryptedChannel", () => {
|
||||
|
||||
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 MAX_PENDING_SENDS = 200;
|
||||
const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008;
|
||||
const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch";
|
||||
|
||||
interface TimeoutWithUnref {
|
||||
unref(): void;
|
||||
@@ -420,16 +422,14 @@ export class EncryptedChannel {
|
||||
return;
|
||||
}
|
||||
|
||||
// Different key implies a new client connection (common with relays
|
||||
// where the daemon's socket stays open while the client reconnects).
|
||||
// Re-key and re-send "ready". Drop any queued sends to avoid leaking
|
||||
// messages between logical client sessions.
|
||||
this.state = "handshaking";
|
||||
this.sharedKey = nextSharedKey;
|
||||
this.pendingSends = [];
|
||||
this.transport.send(JSON.stringify({ type: "e2ee_ready" } satisfies E2EEReadyMessage));
|
||||
this.state = "open";
|
||||
await this.flushPendingSends();
|
||||
// A different key on an already-open encrypted channel is not an
|
||||
// authenticated reconnect. Close and require a fresh transport instead of
|
||||
// allowing the relay to switch this channel to an attacker-chosen key.
|
||||
this.state = "closed";
|
||||
this.transport.close(
|
||||
REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE,
|
||||
REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON,
|
||||
);
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "Normal closure"): void {
|
||||
@@ -452,8 +452,9 @@ export class EncryptedChannel {
|
||||
|
||||
function keysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.byteLength !== b.byteLength) return false;
|
||||
let difference = 0;
|
||||
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",
|
||||
"source": "./src/utils/tool-call-parsers.ts",
|
||||
"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": {
|
||||
@@ -61,9 +66,6 @@
|
||||
"@getpaseo/highlight": "0.1.78",
|
||||
"@getpaseo/relay": "0.1.78",
|
||||
"@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",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@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 () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
AgentCreatedStatusPayloadSchema,
|
||||
AgentRefreshedStatusPayloadSchema,
|
||||
AgentResumedStatusPayloadSchema,
|
||||
CheckoutRenameBranchResponseSchema,
|
||||
parseServerInfoStatusPayload,
|
||||
RenameTerminalResponseSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
ShutdownRequestedStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
@@ -60,6 +62,8 @@ import type {
|
||||
GetProvidersSnapshotResponseMessage,
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
ProviderDiagnosticResponseMessage,
|
||||
DaemonGetStatusResponse,
|
||||
DaemonGetPairingOfferResponse,
|
||||
ListTerminalsResponse,
|
||||
CreateTerminalResponse,
|
||||
SubscribeTerminalResponse,
|
||||
@@ -285,6 +289,7 @@ type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["pay
|
||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
||||
export type RenameBranchResult = z.infer<typeof CheckoutRenameBranchResponseSchema>["payload"];
|
||||
type StashSavePayload = StashSaveResponse["payload"];
|
||||
type StashPopPayload = StashPopResponse["payload"];
|
||||
type StashListPayload = StashListResponse["payload"];
|
||||
@@ -317,6 +322,8 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
||||
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||
type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
|
||||
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
|
||||
type ReadProjectConfigPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "read_project_config_response" }
|
||||
@@ -351,6 +358,7 @@ type DictationFinishAcceptedPayload = Extract<
|
||||
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
||||
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"];
|
||||
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
||||
type CloseItemsPayload = CloseItemsResponse["payload"];
|
||||
type KillTerminalPayload = KillTerminalResponse["payload"];
|
||||
@@ -614,6 +622,16 @@ export interface UpdateScheduleOptions {
|
||||
expiresAt?: string | null;
|
||||
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 OpenInEditorPayload = OpenInEditorResponseMessage["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(
|
||||
cwd: 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(
|
||||
config: MutableDaemonConfigPatch,
|
||||
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(
|
||||
terminalId: string,
|
||||
requestId?: string,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user