mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e529c3601f | ||
|
|
4534754617 | ||
|
|
d1481833e6 | ||
|
|
acea7f3d24 | ||
|
|
2b0740ff84 | ||
|
|
7af92120fe | ||
|
|
cda66ae5f3 | ||
|
|
f9660c7e89 | ||
|
|
e73b1c4724 | ||
|
|
ccb8714a71 | ||
|
|
b90baaff71 | ||
|
|
d0189f3f65 |
@@ -21,6 +21,8 @@ Agent-scoped `create_agent` accepts `detached: true` for agents that should stan
|
||||
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
|
||||
|
||||
## Archive
|
||||
@@ -70,6 +72,8 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
|
||||
## Why this shape
|
||||
|
||||
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
|
||||
@@ -77,6 +81,7 @@ The decision was to **decouple "close tab" from "archive" only for subagents**,
|
||||
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
|
||||
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
|
||||
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
|
||||
- **Detach button on track rows** — lets a subagent continue independently without killing its work
|
||||
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
|
||||
|
||||
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
|
||||
|
||||
+19
-2
@@ -54,6 +54,21 @@ Boundary tests should assert observable behavior: cold reads may call provider a
|
||||
|
||||
---
|
||||
|
||||
## Provider Usage Fetchers
|
||||
|
||||
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
|
||||
|
||||
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
|
||||
|
||||
- `providerId`, `displayName`, `status`, and optional `planLabel`
|
||||
- any number of `windows` such as Session, Weekly, or Biweekly
|
||||
- optional `balances` for credits, USD, requests, or tokens
|
||||
- optional `details` for provider-specific rows
|
||||
|
||||
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
|
||||
|
||||
---
|
||||
|
||||
## ACP Provider Checklist
|
||||
|
||||
### 1. Create the provider class
|
||||
@@ -343,7 +358,7 @@ interface AgentSession {
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
getAvailableModes(): Promise<AgentMode[]>;
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
setMode(modeId: string): Promise<void | AgentProviderNotice>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(
|
||||
requestId: string,
|
||||
@@ -355,7 +370,7 @@ interface AgentSession {
|
||||
// Optional:
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||
@@ -363,6 +378,8 @@ interface AgentSession {
|
||||
}
|
||||
```
|
||||
|
||||
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-0z+saIsaIOAeRV3lXS7glaooUDmz1d8iYYigPzRLbgA=
|
||||
sha256-oJSIAxDUwa/MXkCfKuQR6Owb6/YykAYP/mRKVCGK+fQ=
|
||||
|
||||
Generated
+25
-51
@@ -11207,8 +11207,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11221,8 +11220,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11235,8 +11233,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11249,8 +11246,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11263,8 +11259,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11277,8 +11272,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
@@ -11291,8 +11285,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
@@ -11305,8 +11298,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11319,8 +11311,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11333,8 +11324,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11347,8 +11337,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11361,8 +11350,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11375,8 +11363,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11389,8 +11376,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11403,8 +11389,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11417,8 +11402,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11431,8 +11415,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11445,8 +11428,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11459,8 +11441,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11473,8 +11454,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11487,8 +11467,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11501,8 +11480,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11515,8 +11493,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11529,8 +11506,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11543,8 +11519,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
@@ -18272,7 +18247,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
||||
@@ -362,11 +362,13 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||
}
|
||||
return { id: opened.workspace.id };
|
||||
return { id: createdWorkspace.workspace.id };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
@@ -45,11 +47,58 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise<
|
||||
await removeItem.click();
|
||||
}
|
||||
|
||||
// Model B makes the project a first-class parent: archiving its last workspace
|
||||
// must not delete the project. The per-project "+ New workspace" row is gone;
|
||||
// the empty project keeps its parent row, and creation stays reachable from the
|
||||
// project row's own new-worktree icon (git projects) and the global button.
|
||||
test.describe("Empty project persists", () => {
|
||||
async function addProjectFromPicker(page: Page, projectPath: string): Promise<string> {
|
||||
await page.getByTestId("sidebar-add-project").click();
|
||||
|
||||
const input = page.getByPlaceholder("Type a directory path...");
|
||||
await expect(input).toBeVisible({ timeout: 30_000 });
|
||||
await input.fill(projectPath);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: path.basename(projectPath) })
|
||||
.first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const testId = await projectRow.getAttribute("data-testid");
|
||||
expect(testId).not.toBeNull();
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
}
|
||||
|
||||
// Projects are parents in the sidebar. Archiving the last workspace leaves the
|
||||
// project row in place with a ghost "+ New workspace" child row.
|
||||
test.describe("Project with no workspaces persists", () => {
|
||||
test("adding a project starts with only a new-workspace child row", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("empty-project-add-");
|
||||
const client = await connectSeedClient();
|
||||
let projectId: string | null = null;
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForNoProjectsInSidebar(page);
|
||||
|
||||
projectId = await addProjectFromPicker(page, repo.path);
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(path.basename(repo.path));
|
||||
await expect(page.getByTestId(`sidebar-workspace-list-${projectId}`)).toHaveCount(0);
|
||||
|
||||
const newWorkspaceRow = page.getByTestId(`sidebar-project-new-workspace-row-${projectId}`);
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
|
||||
const workspaces = await client.fetchWorkspaces({ filter: { projectId } });
|
||||
expect(workspaces.entries).toEqual([]);
|
||||
} finally {
|
||||
if (projectId) {
|
||||
await removeProjectFromSidebar(page, projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("archiving the only workspace keeps the project row with creation still reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -57,8 +106,8 @@ test.describe("Empty project persists", () => {
|
||||
|
||||
try {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
||||
const projectNewWorktreeIcon = page.getByTestId(
|
||||
`sidebar-project-new-worktree-${workspace.projectId}`,
|
||||
const newWorkspaceRow = page.getByTestId(
|
||||
`sidebar-project-new-workspace-row-${workspace.projectId}`,
|
||||
);
|
||||
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
|
||||
|
||||
@@ -71,24 +120,21 @@ test.describe("Empty project persists", () => {
|
||||
|
||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
// The workspace row goes away, but its project parent stays as an empty
|
||||
// project row. Creation is still reachable: the project row keeps its own
|
||||
// new-worktree icon (revealed on hover) and the global button persists.
|
||||
// The workspace row goes away, but its project parent stays and exposes a
|
||||
// child row for creating the next workspace.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
await expect(globalNewWorkspace).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The empty project survives a reload — it is persisted, not a transient
|
||||
// artifact of the just-archived workspace still lingering in memory.
|
||||
// The project survives a reload after its last workspace is archived.
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -120,16 +166,20 @@ test.describe("Project remove", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
const reopened = await workspace.client.openProject(workspace.repoPath);
|
||||
expect(reopened.error).toBeNull();
|
||||
expect(reopened.workspace?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||
expect(readded.error).toBeNull();
|
||||
expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
||||
await expect(projectRow).not.toContainText(workspace.repoPath);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await removeProjectFromSidebar(page, workspace.projectId).catch(() => undefined);
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ function buildSeededStoragePayload() {
|
||||
* idle agent from the same client it uses for everything else.
|
||||
*/
|
||||
export interface IdleAgentSeedClient {
|
||||
openProject(cwd: string): Promise<{
|
||||
createWorkspace(input: { source: { kind: "directory"; path: string } }): Promise<{
|
||||
workspace: { id: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
@@ -58,16 +58,18 @@ export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: input.cwd },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
|
||||
}
|
||||
const created = await client.createAgent({
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: input.cwd,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
title: input.title,
|
||||
});
|
||||
const snapshot = await client.waitForAgentUpsert(
|
||||
@@ -82,7 +84,7 @@ export async function createIdleAgent(
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -160,15 +160,19 @@ export async function startRunningMockAgent(
|
||||
|
||||
const repo = await createTempGitRepo(opts.prefix);
|
||||
const client = await connectSeedClient();
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
model: opts.model,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, opened.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, createdWorkspace.workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expectComposerVisible(page);
|
||||
await client.sendAgentMessage(agent.id, opts.prompt);
|
||||
@@ -188,10 +192,14 @@ export async function openGithubWorkspace(
|
||||
repoPath: string,
|
||||
): Promise<GithubWorkspaceHandle> {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const opened = await client.openProject(repoPath);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoPath },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
|
||||
}
|
||||
await gotoAppShell(page);
|
||||
await selectWorkspaceInSidebar(page, opened.workspace.id);
|
||||
await selectWorkspaceInSidebar(page, createdWorkspace.workspace.id);
|
||||
await waitForTabBar(page);
|
||||
return { cleanup: () => client.close().catch(() => undefined) };
|
||||
}
|
||||
|
||||
@@ -13,16 +13,16 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "close"
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "openProject"
|
||||
| "patchDaemonConfig"
|
||||
>;
|
||||
|
||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||
type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">;
|
||||
type WorkspaceDescriptor = NonNullable<OpenProjectPayload["workspace"]>;
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
type WorkspacePayload = Pick<CreateWorkspacePayload, "error" | "workspace">;
|
||||
type WorkspaceDescriptor = NonNullable<CreateWorkspacePayload["workspace"]>;
|
||||
|
||||
export interface OpenedProject {
|
||||
workspaceId: string;
|
||||
@@ -37,7 +37,7 @@ function requireWorkspace(payload: WorkspacePayload) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.workspace) {
|
||||
throw new Error("openProject returned no workspace.");
|
||||
throw new Error("workspace.create returned no workspace.");
|
||||
}
|
||||
return payload.workspace;
|
||||
}
|
||||
@@ -96,7 +96,11 @@ export async function openProjectViaDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<OpenedProject> {
|
||||
const workspace = requireWorkspace(await client.openProject(repoPath));
|
||||
const workspace = requireWorkspace(
|
||||
await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoPath },
|
||||
}),
|
||||
);
|
||||
return openedProjectFromWorkspace(workspace);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { ProviderUsage } from "@getpaseo/protocol/messages";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
interface ProviderUsageFixturePayload {
|
||||
fetchedAt: string;
|
||||
providers: ProviderUsage[];
|
||||
}
|
||||
|
||||
export interface ProviderUsageFixture {
|
||||
requestCount(): number;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseJson(message: WebSocketMessage): unknown {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function withProviderUsageFeature(message: WebSocketMessage): string | null {
|
||||
const envelope = parseJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
const payload = maybeEnvelope.message?.payload;
|
||||
if (
|
||||
maybeEnvelope.type !== "session" ||
|
||||
maybeEnvelope.message?.type !== "status" ||
|
||||
payload?.status !== "server_info"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
...maybeEnvelope,
|
||||
message: {
|
||||
...maybeEnvelope.message,
|
||||
payload: {
|
||||
...payload,
|
||||
features: {
|
||||
...(typeof payload.features === "object" && payload.features !== null
|
||||
? payload.features
|
||||
: {}),
|
||||
providerUsageList: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function installProviderUsageFixture(
|
||||
page: Page,
|
||||
payloads: ProviderUsageFixturePayload[],
|
||||
): Promise<ProviderUsageFixture> {
|
||||
let requests = 0;
|
||||
const waiters: Array<{ count: number; resolve: () => void }> = [];
|
||||
|
||||
function notifyWaiters() {
|
||||
for (const waiter of waiters.splice(0)) {
|
||||
if (requests >= waiter.count) {
|
||||
waiter.resolve();
|
||||
} else {
|
||||
waiters.push(waiter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function payloadForRequest(): ProviderUsageFixturePayload {
|
||||
const index = Math.min(requests - 1, payloads.length - 1);
|
||||
const payload = payloads[index];
|
||||
if (!payload) {
|
||||
throw new Error("Provider usage fixture requires at least one payload.");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "provider.usage.list.request") {
|
||||
requests += 1;
|
||||
const requestId = sessionMessage.requestId;
|
||||
if (typeof requestId !== "string") {
|
||||
throw new Error("provider.usage.list.request missing requestId");
|
||||
}
|
||||
const payload = payloadForRequest();
|
||||
notifyWaiters();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId,
|
||||
fetchedAt: payload.fetchedAt,
|
||||
providers: payload.providers,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
requestCount() {
|
||||
return requests;
|
||||
},
|
||||
waitForRequestCount(count: number) {
|
||||
if (requests >= count) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
waiters.push({ count, resolve });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -171,25 +171,27 @@ export async function launchAgent(input: {
|
||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||
const client = await connectSeedClient();
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: input.cwd },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
cwd: input.cwd,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
});
|
||||
const handle = {
|
||||
page: input.page,
|
||||
client,
|
||||
agentId: agent.id,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
cwd: input.cwd,
|
||||
provider: input.provider,
|
||||
};
|
||||
await openAgent(input.page, { workspaceId: opened.workspace.id, agentId: agent.id });
|
||||
await openAgent(input.page, { workspaceId: createdWorkspace.workspace.id, agentId: agent.id });
|
||||
return handle;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||
|
||||
export interface SeedWorkspaceDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||
@@ -13,17 +22,17 @@ import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||
export interface SeedDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
addProject(cwd: string): Promise<{
|
||||
project: {
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
createWorkspace(input: {
|
||||
source:
|
||||
| { kind: "directory"; path: string; projectId?: string }
|
||||
@@ -39,7 +48,7 @@ export interface SeedDaemonClient {
|
||||
};
|
||||
title?: string;
|
||||
}): Promise<{
|
||||
workspace: { id: string; name: string } | null;
|
||||
workspace: SeedWorkspaceDescriptor | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
/**
|
||||
@@ -82,6 +91,7 @@ export interface SeedDaemonClient {
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
@@ -125,7 +135,7 @@ export interface SeedDaemonClient {
|
||||
agentId: string,
|
||||
): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { worktreeRestore?: boolean } | null;
|
||||
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
@@ -183,18 +193,20 @@ export async function seedWorkspace(options: {
|
||||
: await createTempGitRepo(options.repoPrefix, options.repo);
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const opened = await client.openProject(project.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${project.path}`);
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: project.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||
}
|
||||
return {
|
||||
client,
|
||||
repoPath: project.path,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceName: opened.workspace.name,
|
||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
||||
projectId: opened.workspace.projectId,
|
||||
projectDisplayName: opened.workspace.projectDisplayName,
|
||||
workspaceId: created.workspace.id,
|
||||
workspaceName: created.workspace.name,
|
||||
workspaceDirectory: created.workspace.workspaceDirectory,
|
||||
projectId: created.workspace.projectId,
|
||||
projectDisplayName: created.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await project.cleanup().catch(() => undefined);
|
||||
|
||||
@@ -25,7 +25,7 @@ const SECTION_LABELS = {
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
@@ -374,6 +374,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-usage")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
|
||||
|
||||
// The old per-host entry rows are replaced by the host picker.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
export interface SeededSubagentPair {
|
||||
parent: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
child: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export async function seedParentWithSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentTitle: string; childTitle: string },
|
||||
): Promise<SeededSubagentPair> {
|
||||
const parent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.parentTitle,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.childTitle,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
parent: {
|
||||
id: parent.id,
|
||||
title: input.parentTitle,
|
||||
},
|
||||
child: {
|
||||
id: child.id,
|
||||
title: input.childTitle,
|
||||
},
|
||||
workspaceId: workspace.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
}
|
||||
|
||||
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectSubagentRowGone(page: Page, childId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
|
||||
const row = page.getByTestId(`subagents-track-row-${childId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.hover();
|
||||
|
||||
page.once("dialog", (dialog) => {
|
||||
expect(dialog.message()).toContain("Detach subagent?");
|
||||
void dialog.accept();
|
||||
});
|
||||
|
||||
const detachButton = page.getByTestId(`subagents-track-detach-${childId}`);
|
||||
await expect(detachButton).toBeVisible({ timeout: 30_000 });
|
||||
await detachButton.click();
|
||||
}
|
||||
@@ -43,7 +43,9 @@ export class TerminalE2EHarness {
|
||||
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||
const client = await connectSeedClient();
|
||||
const seedResult = await client.openProject(tempRepo.path);
|
||||
const seedResult = await client.createWorkspace({
|
||||
source: { kind: "directory", path: tempRepo.path },
|
||||
});
|
||||
if (!seedResult.workspace) {
|
||||
await client.close().catch(() => {});
|
||||
await tempRepo.cleanup().catch(() => {});
|
||||
|
||||
@@ -60,14 +60,19 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
);
|
||||
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
|
||||
// Register the parent project so the sidebar lists it before we navigate.
|
||||
await client.openProject(repo.path);
|
||||
const added = await client.addProject(repo.path);
|
||||
if (!added.project) {
|
||||
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const opened = await client.openProject(workspacePath);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: workspacePath },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
|
||||
}
|
||||
const workspaceId = opened.workspace.id;
|
||||
const workspaceId = created.workspace.id;
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
|
||||
@@ -11,13 +11,14 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
type WorkspaceSetupDaemonClient = Pick<
|
||||
InternalDaemonClient,
|
||||
| "close"
|
||||
| "addProject"
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "fetchAgent"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "listTerminals"
|
||||
| "openProject"
|
||||
| "subscribeRawMessages"
|
||||
>;
|
||||
|
||||
@@ -36,9 +37,11 @@ export async function openProjectViaDaemon(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
||||
const result = await client.openProject(repoPath);
|
||||
const result = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repoPath },
|
||||
});
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||
throw new Error(result.error ?? `Failed to create workspace ${repoPath}`);
|
||||
}
|
||||
return {
|
||||
id: result.workspace.id,
|
||||
@@ -51,7 +54,10 @@ export async function seedProjectForWorkspaceSetup(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<void> {
|
||||
await openProjectViaDaemon(client, repoPath);
|
||||
const result = await client.addProject(repoPath);
|
||||
if (!result.project || result.error) {
|
||||
throw new Error(result.error ?? `Failed to add project ${repoPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function projectNameFromPath(repoPath: string): string {
|
||||
|
||||
@@ -8,8 +8,8 @@ export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the sidebar to show at least one project row, indicating that the
|
||||
* WebSocket connection is up and workspace hydration has completed.
|
||||
* Wait for the sidebar to show at least one project row. Use this after a spec
|
||||
* seeds a workspace/project; zero-project flows need their own assertion.
|
||||
*/
|
||||
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
|
||||
await page
|
||||
@@ -18,6 +18,10 @@ export async function waitForSidebarHydration(page: Page, timeout = 60_000): Pro
|
||||
.waitFor({ state: "visible", timeout });
|
||||
}
|
||||
|
||||
export async function waitForNoProjectsInSidebar(page: Page, timeout = 60_000): Promise<void> {
|
||||
await page.getByTestId("sidebar-project-empty-state").waitFor({ state: "visible", timeout });
|
||||
}
|
||||
|
||||
function workspaceRowLocator(page: Page, serverId: string, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).first();
|
||||
}
|
||||
|
||||
@@ -58,9 +58,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
||||
await launchOpenCodeSessionInWorkspace(PASEO_REPO_PATH, prompt);
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const opened = await client.openProject(PASEO_REPO_PATH);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${PASEO_REPO_PATH}`);
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: PASEO_REPO_PATH },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${PASEO_REPO_PATH}`);
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
@@ -69,11 +71,11 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise<OpenCodeImportSc
|
||||
workspace: {
|
||||
client,
|
||||
repoPath: PASEO_REPO_PATH,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceName: opened.workspace.name,
|
||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
||||
projectId: opened.workspace.projectId,
|
||||
projectDisplayName: opened.workspace.projectDisplayName,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
workspaceName: createdWorkspace.workspace.name,
|
||||
workspaceDirectory: createdWorkspace.workspace.workspaceDirectory,
|
||||
projectId: createdWorkspace.workspace.projectId,
|
||||
projectDisplayName: createdWorkspace.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { installProviderUsageFixture } from "./helpers/provider-usage";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { openSettingsHostSection } from "./helpers/settings";
|
||||
|
||||
test.describe("provider usage settings", () => {
|
||||
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 7 }],
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
|
||||
},
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
sourceLabel: "OpenUsage 0.6.27",
|
||||
windows: [
|
||||
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
|
||||
{ id: "daily", label: "Daily", remainingPct: 30 },
|
||||
],
|
||||
balances: [
|
||||
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
|
||||
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
|
||||
],
|
||||
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
|
||||
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("refresh invalidates and refetches usage", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("one provider error does not collapse the usage list", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "error",
|
||||
planLabel: null,
|
||||
windows: [],
|
||||
error: "Claude auth expired",
|
||||
},
|
||||
{
|
||||
providerId: "codex",
|
||||
displayName: "Codex",
|
||||
status: "available",
|
||||
planLabel: "Pro 20x",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
|
||||
const card = page.getByTestId("provider-usage-card");
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("Error", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("71%")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { installProviderUsageFixture } from "./helpers/provider-usage";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function openMockAgent(page: Page) {
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-usage-tooltip-",
|
||||
title: "Provider usage tooltip e2e",
|
||||
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("provider usage tooltip", () => {
|
||||
test("fetches usage when the context tooltip opens and renders the active provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
usedPct: 42,
|
||||
remainingPct: 58,
|
||||
resetsAt: "2026-06-19T05:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
expect(usageFixture.requestCount()).toBe(0);
|
||||
|
||||
await page.getByTestId("context-window-meter").hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("Test plan")).toBeVisible();
|
||||
await expect(page.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("42%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usageFixture = await installProviderUsageFixture(page, [
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 41 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fetchedAt: "2026-06-19T00:01:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "mock",
|
||||
displayName: "Mock provider",
|
||||
status: "available",
|
||||
planLabel: "Test plan",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 64 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const session = await openMockAgent(page);
|
||||
try {
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(1);
|
||||
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
|
||||
|
||||
await meter.hover();
|
||||
await usageFixture.waitForRequestCount(2);
|
||||
expect(usageFixture.requestCount()).toBe(2);
|
||||
await expect(page.getByText("64%")).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from "./fixtures";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "./helpers/launcher";
|
||||
import { openAgentRoute } from "./helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
detachSubagentFromTrack,
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentsTrack,
|
||||
seedParentWithSubagent,
|
||||
} from "./helpers/subagents";
|
||||
|
||||
test.describe("Subagent detach", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({ repoPrefix: "subagent-detach-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("detaching a subagent focuses it as a workspace tab", async ({ page }) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Detach parent",
|
||||
childTitle: "Detached child",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agents.workspaceId,
|
||||
agentId: agents.parent.id,
|
||||
});
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
await detachSubagentFromTrack(page, agents.child.id);
|
||||
|
||||
await expectSubagentRowGone(page, agents.child.id);
|
||||
await expectWorkspaceTabVisible(page, agents.child.id);
|
||||
await expectAgentTabActive(page, agents.child.id);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
|
||||
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||
import { formatTokenCount } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
totalCostUsd?: number | null;
|
||||
showPercentage?: boolean;
|
||||
serverId?: string;
|
||||
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
|
||||
provider?: string | null;
|
||||
}
|
||||
|
||||
const SVG_SIZE = 16;
|
||||
@@ -41,16 +48,6 @@ function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
|
||||
function formatSessionCost(value: number): string | null {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
@@ -75,15 +72,52 @@ function getMeterColors(
|
||||
return { progress: theme.colors.foregroundMuted, track };
|
||||
}
|
||||
|
||||
function getMeterGeometry(showPercentage: boolean) {
|
||||
if (showPercentage) {
|
||||
return {
|
||||
svgSize: COMPACT_SVG_SIZE,
|
||||
center: COMPACT_CENTER,
|
||||
radius: COMPACT_RADIUS,
|
||||
strokeWidth: COMPACT_STROKE_WIDTH,
|
||||
circumference: COMPACT_CIRCUMFERENCE,
|
||||
containerStyle: styles.containerWithLabel,
|
||||
};
|
||||
}
|
||||
return {
|
||||
svgSize: SVG_SIZE,
|
||||
center: CENTER,
|
||||
radius: RADIUS,
|
||||
strokeWidth: STROKE_WIDTH,
|
||||
circumference: CIRCUMFERENCE,
|
||||
containerStyle: styles.container,
|
||||
};
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({
|
||||
maxTokens,
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
showPercentage = false,
|
||||
serverId,
|
||||
provider,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
|
||||
serverId ?? null,
|
||||
{ enabled: isTooltipOpen },
|
||||
);
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
const handleTooltipOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setIsTooltipOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
void refreshProviderUsage();
|
||||
}
|
||||
},
|
||||
[refreshProviderUsage],
|
||||
);
|
||||
|
||||
if (percentage === null) {
|
||||
return null;
|
||||
@@ -91,22 +125,25 @@ export function ContextWindowMeter({
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const svgSize = showPercentage ? COMPACT_SVG_SIZE : SVG_SIZE;
|
||||
const center = showPercentage ? COMPACT_CENTER : CENTER;
|
||||
const radius = showPercentage ? COMPACT_RADIUS : RADIUS;
|
||||
const strokeWidth = showPercentage ? COMPACT_STROKE_WIDTH : STROKE_WIDTH;
|
||||
const circumference = showPercentage ? COMPACT_CIRCUMFERENCE : CIRCUMFERENCE;
|
||||
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } =
|
||||
getMeterGeometry(showPercentage);
|
||||
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
const formattedSessionCost =
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
const containerStyle = showPercentage ? styles.containerWithLabel : styles.container;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<Tooltip
|
||||
open={isTooltipOpen}
|
||||
onOpenChange={handleTooltipOpenChange}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t("contextWindow.accessibility", {
|
||||
percentage: roundedPercentage,
|
||||
@@ -162,6 +199,7 @@ export function ContextWindowMeter({
|
||||
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
||||
</Text>
|
||||
) : null}
|
||||
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -194,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 200,
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function formatTokenCount(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${Math.round(value / 1_000_000)}m`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}k`;
|
||||
}
|
||||
return Math.round(value).toString();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
@@ -248,6 +250,7 @@ function DiagnosticSubSheet({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -288,31 +291,62 @@ function DiagnosticSubSheet({
|
||||
void fetchDiagnostic();
|
||||
}, [fetchDiagnostic]);
|
||||
|
||||
const copyButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
sheetStyles.iconButton,
|
||||
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
|
||||
diagnostic ? null : sheetStyles.disabled,
|
||||
],
|
||||
[diagnostic],
|
||||
);
|
||||
|
||||
const handleCopyPress = useCallback(() => {
|
||||
if (!diagnostic) return;
|
||||
void Clipboard.setStringAsync(diagnostic)
|
||||
.then(() => toast.copied(t("settings.providers.diagnostic.copyLabel")))
|
||||
.catch(() => toast.error(t("settings.providers.diagnostic.copyFailed")));
|
||||
}, [diagnostic, t, toast]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("settings.providers.diagnostic.title"),
|
||||
actions: (
|
||||
<Pressable
|
||||
onPress={handleRefreshPress}
|
||||
disabled={loading}
|
||||
hitSlop={8}
|
||||
style={refreshButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
loading
|
||||
? t("settings.providers.diagnostic.refreshingAccessibility")
|
||||
: t("settings.providers.diagnostic.refreshAccessibility")
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={sheetStyles.headerActions}>
|
||||
<Pressable
|
||||
onPress={handleCopyPress}
|
||||
disabled={!diagnostic}
|
||||
hitSlop={8}
|
||||
style={copyButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
|
||||
>
|
||||
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefreshPress}
|
||||
disabled={loading}
|
||||
hitSlop={8}
|
||||
style={refreshButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
loading
|
||||
? t("settings.providers.diagnostic.refreshingAccessibility")
|
||||
: t("settings.providers.diagnostic.refreshAccessibility")
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
),
|
||||
}),
|
||||
[
|
||||
copyButtonStyle,
|
||||
diagnostic,
|
||||
handleCopyPress,
|
||||
handleRefreshPress,
|
||||
loading,
|
||||
refreshButtonStyle,
|
||||
@@ -733,6 +767,11 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -21,45 +20,12 @@ import {
|
||||
CircleCheck,
|
||||
CircleDot,
|
||||
CircleX,
|
||||
MoreVertical,
|
||||
Copy,
|
||||
Archive,
|
||||
Pencil,
|
||||
} from "lucide-react-native";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { useSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { SidebarWorkspaceRow } from "@/components/sidebar/sidebar-workspace-row";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
|
||||
// Themed icon wrappers
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
@@ -74,17 +40,6 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const markAsReadLeadingIcon = (
|
||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
interface StatusWorkspaceListProps {
|
||||
workspaces: SidebarWorkspaceEntry[];
|
||||
@@ -320,448 +275,18 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
if (!hydratedWorkspace) return null;
|
||||
|
||||
return (
|
||||
<StatusWorkspaceRowWithMenu
|
||||
<SidebarWorkspaceRow
|
||||
workspace={hydratedWorkspace}
|
||||
projectName={projectName}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
canCopyBranchName={hydratedWorkspace.projectKind === "git"}
|
||||
onPress={handlePress}
|
||||
subtitle={projectName}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function StatusWorkspaceRowWithMenu({
|
||||
workspace,
|
||||
projectName,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
projectName: string;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
activeWorkspaceSelection: selected
|
||||
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
|
||||
: null,
|
||||
});
|
||||
}, [selected, workspace]);
|
||||
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
const handleArchive = useCallback(() => {
|
||||
if (isArchiving) return;
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied("Path copied");
|
||||
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.name);
|
||||
toast.copied("Branch name copied");
|
||||
}, [toast, workspace.name]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
|
||||
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
handleArchive();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
|
||||
if (isWorktree) {
|
||||
computedArchiveStatus = worktreeArchiveStatus;
|
||||
} else if (isHidingWorkspace) {
|
||||
computedArchiveStatus = "pending";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusWorkspaceRowInner
|
||||
workspace={workspace}
|
||||
projectName={projectName}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
isArchiving={isArchiving}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={computedArchiveStatus}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
title="Rename workspace"
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel="Rename"
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusWorkspaceRowInner({
|
||||
workspace,
|
||||
projectName,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
isArchiving,
|
||||
archiveLabel,
|
||||
archiveStatus = "idle",
|
||||
archivePendingLabel,
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
projectName: string;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
isArchiving: boolean;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||
const hasRunningService = workspace.scripts.some(
|
||||
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
||||
);
|
||||
let scriptIconKind: "service" | "command" | null = null;
|
||||
if (showScriptsIcon) {
|
||||
scriptIconKind = hasRunningService ? "service" : "command";
|
||||
}
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace}>
|
||||
{({ isHovered, hoverHandlers }) => {
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
const showKebabInSlot = showKebab && !showShortcut;
|
||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
||||
return (
|
||||
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
onPress={onPress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
subtitle={projectName}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
>
|
||||
{shouldRenderActionSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
||||
showOverlay={showKebabInSlot}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceRowContent>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</SidebarWorkspaceRowFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusWorkspaceActionSlot({
|
||||
workspace,
|
||||
showBase,
|
||||
showOverlay,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
showBase: boolean;
|
||||
showOverlay: boolean;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
return (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showBase}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
|
||||
{onArchive ? (
|
||||
<StatusKebabMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionOverlay>
|
||||
</SidebarWorkspaceTrailingActionSlot>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusKebabMenu({
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspaceKey: string;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={kebabStyle}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||
>
|
||||
{({ hovered }: { hovered?: boolean }) => (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
Rename workspace
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMarkAsRead ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||
leading={markAsReadLeadingIcon}
|
||||
onSelect={onMarkAsRead}
|
||||
>
|
||||
Mark as read
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
trailing={archiveTrailing}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function getStatusWorkspaceRowStyle({
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -825,37 +350,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
workspaceRowContainer: {
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
workspaceRowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -29,7 +29,6 @@ import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
@@ -220,7 +219,9 @@ function WorkspaceStatusIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
if (bucket === "done") return null;
|
||||
if (bucket === "done") {
|
||||
return <View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />;
|
||||
}
|
||||
|
||||
let KindIcon: typeof ThemedMonitor;
|
||||
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
||||
@@ -503,7 +504,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
workspaceStatusDot: {
|
||||
position: "relative",
|
||||
width: WORKSPACE_STATUS_DOT_WIDTH,
|
||||
width: theme.iconSize.md,
|
||||
height: 20,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
SidebarWorkspaceTrailingActionBase,
|
||||
SidebarWorkspaceTrailingActionOverlay,
|
||||
SidebarWorkspaceTrailingActionSlot,
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const markAsReadLeadingIcon = (
|
||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
interface SidebarWorkspaceRowProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
canCopyBranchName: boolean;
|
||||
onPress: () => void;
|
||||
/** Secondary line under the name (status grouping shows the project name). */
|
||||
subtitle?: string | null;
|
||||
/** Project grouping only: shows a transient "creating" affordance. */
|
||||
isCreating?: boolean;
|
||||
/** Project grouping only: drag-to-reorder wiring. Absent → not draggable. */
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceRow({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
canCopyBranchName,
|
||||
onPress,
|
||||
subtitle,
|
||||
isCreating = false,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: SidebarWorkspaceRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
activeWorkspaceSelection: selected
|
||||
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
|
||||
: null,
|
||||
});
|
||||
}, [selected, workspace]);
|
||||
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
const handleArchive = useCallback(() => {
|
||||
if (isArchiving) {
|
||||
return;
|
||||
}
|
||||
archiveController.archive();
|
||||
}, [archiveController, isArchiving]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
copyTargetDirectory = requireWorkspaceDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(copyTargetDirectory);
|
||||
toast.copied(t("sidebar.workspace.toasts.pathCopied"));
|
||||
}, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
if (!workspace.currentBranch) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(workspace.currentBranch);
|
||||
toast.copied(t("sidebar.workspace.toasts.branchNameCopied"));
|
||||
}, [t, toast, workspace.currentBranch]);
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title);
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenRename = useCallback(() => {
|
||||
setIsRenameOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseRename = useCallback(() => {
|
||||
setIsRenameOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleSubmitRename = useCallback(
|
||||
async (value: string) => {
|
||||
await renameMutation.mutateAsync(value.trim());
|
||||
},
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
handleArchive();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
let archiveStatus: "idle" | "pending" | "success" = "idle";
|
||||
if (isWorktree) {
|
||||
archiveStatus = worktreeArchiveStatus;
|
||||
} else if (isHidingWorkspace) {
|
||||
archiveStatus = "pending";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceRowBody
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
subtitle={subtitle}
|
||||
isCreating={isCreating}
|
||||
isArchiving={isArchiving}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
title={t("sidebar.workspace.rename.title")}
|
||||
initialValue={workspace.title ?? workspace.name}
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
onClose={handleCloseRename}
|
||||
onSubmit={handleSubmitRename}
|
||||
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceRowBodyProps {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
subtitle?: string | null;
|
||||
isCreating: boolean;
|
||||
isArchiving: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
function WorkspaceRowBody({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
subtitle,
|
||||
isCreating,
|
||||
isArchiving,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
archiveLabel,
|
||||
archiveStatus = "idle",
|
||||
archivePendingLabel,
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowBodyProps) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const draggable = Boolean(drag);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag: drag ?? noop,
|
||||
menuController: null,
|
||||
});
|
||||
const {
|
||||
role: _dragRole,
|
||||
tabIndex: _dragTabIndex,
|
||||
"aria-roledescription": _dragRoleDescription,
|
||||
...dragAttributes
|
||||
} = dragHandleProps?.attributes ?? {};
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
interaction.didLongPressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onPress();
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
return (
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
{({ isHovered, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
|
||||
const hasRunningService = workspace.scripts.some(
|
||||
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
|
||||
);
|
||||
let scriptIconKind: "service" | "command" | null = null;
|
||||
if (showScriptsIcon) {
|
||||
scriptIconKind = hasRunningService ? "service" : "command";
|
||||
}
|
||||
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
{...(draggable ? dragAttributes : {})}
|
||||
{...(draggable ? dragHandleProps?.listeners : {})}
|
||||
ref={
|
||||
draggable ? (dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>) : undefined
|
||||
}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
>
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={workspaceRowStyle}
|
||||
onPressIn={draggable ? interaction.handlePressIn : undefined}
|
||||
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
|
||||
onPressOut={draggable ? interaction.handlePressOut : undefined}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
subtitle={subtitle}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
>
|
||||
<WorkspaceRowTrailingActions
|
||||
workspace={workspace}
|
||||
isHovered={isHovered}
|
||||
isTouchPlatform={isTouchPlatform}
|
||||
isCreating={isCreating}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
shortcutNumber={shortcutNumber}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
onArchive={onArchive}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
/>
|
||||
</SidebarWorkspaceRowContent>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</SidebarWorkspaceRowFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRowTrailingActions({
|
||||
workspace,
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
isCreating,
|
||||
showShortcutBadge,
|
||||
shortcutNumber,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
onArchive,
|
||||
onMarkAsRead,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
isHovered: boolean;
|
||||
isTouchPlatform: boolean;
|
||||
isCreating: boolean;
|
||||
showShortcutBadge: boolean;
|
||||
shortcutNumber: number | null;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
onArchive?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
|
||||
const showKebabInSlot = showKebab && !showShortcut;
|
||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCreating ? (
|
||||
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
|
||||
) : null}
|
||||
{shouldRenderActionSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase
|
||||
visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
|
||||
>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
|
||||
{onArchive ? (
|
||||
<WorkspaceKebabMenu
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={archivePendingLabel}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionOverlay>
|
||||
</SidebarWorkspaceTrailingActionSlot>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceKebabMenu({
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
archivePendingLabel,
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspaceKey: string;
|
||||
onCopyPath?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
archivePendingLabel?: string;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={workspaceKebabStyle}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel={t("sidebar.workspace.actions.menu")}
|
||||
testID={`sidebar-workspace-kebab-${workspaceKey}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
{t("sidebar.workspace.actions.copyPath")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
|
||||
leading={copyLeadingIcon}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
{t("sidebar.workspace.actions.copyBranchName")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onRename ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
|
||||
leading={renameLeadingIcon}
|
||||
onSelect={onRename}
|
||||
>
|
||||
{t("sidebar.workspace.actions.rename")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onMarkAsRead ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
|
||||
leading={markAsReadLeadingIcon}
|
||||
onSelect={onMarkAsRead}
|
||||
>
|
||||
Mark as read
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
|
||||
leading={archiveLeadingIcon}
|
||||
trailing={archiveTrailing}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? t("sidebar.workspace.actions.archive")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function workspaceKebabStyle({
|
||||
hovered = false,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function getWorkspaceRowStyle({
|
||||
isDragging,
|
||||
selected,
|
||||
isHovered,
|
||||
}: {
|
||||
isDragging: boolean;
|
||||
selected: boolean;
|
||||
isHovered: boolean;
|
||||
}) {
|
||||
return [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.workspaceRowHovered,
|
||||
];
|
||||
}
|
||||
|
||||
export const MemoSidebarWorkspaceRow = memo(SidebarWorkspaceRow);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowContainer: {
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
userSelect: "none",
|
||||
},
|
||||
workspaceRowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceRowDragging: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
transform: [{ scale: 1.02 }],
|
||||
zIndex: 3,
|
||||
...theme.shadow.md,
|
||||
},
|
||||
sidebarRowSelected: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
workspaceCreatingText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
},
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
import type { useContextMenu } from "@/components/ui/context-menu";
|
||||
|
||||
export function useLongPressDragInteraction(input: {
|
||||
drag: () => void;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
}) {
|
||||
const didLongPressRef = useRef(false);
|
||||
const dragArmedRef = useRef(false);
|
||||
const dragActivatedRef = useRef(false);
|
||||
const didStartDragRef = useRef(false);
|
||||
const scrollIntentRef = useRef(false);
|
||||
const menuOpenedRef = useRef(false);
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (dragArmTimerRef.current) {
|
||||
clearTimeout(dragArmTimerRef.current);
|
||||
dragArmTimerRef.current = null;
|
||||
}
|
||||
if (contextMenuTimerRef.current) {
|
||||
clearTimeout(contextMenuTimerRef.current);
|
||||
contextMenuTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openContextMenuAtStartPoint = useCallback(() => {
|
||||
if (!input.menuController || !touchStartRef.current) {
|
||||
return;
|
||||
}
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
input.menuController.setAnchorRect({
|
||||
x: touchStartRef.current.x,
|
||||
y: touchStartRef.current.y + statusBarHeight,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
input.menuController.setOpen(true);
|
||||
menuOpenedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
}, [input.menuController]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
// Manual timers own long-press behavior on mobile.
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimers();
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
const armTimers = useCallback(() => {
|
||||
clearTimers();
|
||||
|
||||
const DRAG_ARM_DELAY_MS = 180;
|
||||
const DRAG_ARM_STATIONARY_SLOP_PX = 4;
|
||||
const CONTEXT_MENU_DELAY_MS = 450;
|
||||
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
|
||||
|
||||
dragArmTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
dragArmedRef.current = true;
|
||||
dragActivatedRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
input.drag();
|
||||
}, DRAG_ARM_DELAY_MS);
|
||||
|
||||
if (!input.menuController || platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
contextMenuTimerRef.current = setTimeout(() => {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
const start = touchStartRef.current;
|
||||
const current = touchCurrentRef.current ?? start;
|
||||
if (!start || !current) {
|
||||
return;
|
||||
}
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
|
||||
return;
|
||||
}
|
||||
void Haptics.selectionAsync().catch(() => {});
|
||||
openContextMenuAtStartPoint();
|
||||
}, CONTEXT_MENU_DELAY_MS);
|
||||
}, [clearTimers, input, openContextMenuAtStartPoint]);
|
||||
|
||||
const handleDragIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
if (!dragActivatedRef.current) {
|
||||
return;
|
||||
}
|
||||
didStartDragRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleScrollIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
scrollIntentRef.current = true;
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handleSwipeIntent = useCallback(
|
||||
(_details: { dx: number; dy: number; distance: number }) => {
|
||||
didLongPressRef.current = true;
|
||||
clearTimers();
|
||||
},
|
||||
[clearTimers],
|
||||
);
|
||||
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
didLongPressRef.current = false;
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
didStartDragRef.current = false;
|
||||
scrollIntentRef.current = false;
|
||||
menuOpenedRef.current = false;
|
||||
touchStartRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
touchCurrentRef.current = {
|
||||
x: event.nativeEvent.pageX,
|
||||
y: event.nativeEvent.pageY,
|
||||
};
|
||||
armTimers();
|
||||
},
|
||||
[armTimers],
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
const start = touchStartRef.current;
|
||||
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
|
||||
const x = touch?.pageX;
|
||||
const y = touch?.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = { x, y };
|
||||
touchCurrentRef.current = current;
|
||||
const dx = current.x - start.x;
|
||||
const dy = current.y - start.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const decision = decideLongPressMove({
|
||||
dragArmed: dragArmedRef.current,
|
||||
didStartDrag: didStartDragRef.current,
|
||||
startPoint: start,
|
||||
currentPoint: current,
|
||||
});
|
||||
|
||||
if (decision === "vertical_scroll") {
|
||||
handleScrollIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
|
||||
handleSwipeIntent({ dx, dy, distance });
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision === "start_drag") {
|
||||
handleDragIntent({ dx, dy, distance });
|
||||
}
|
||||
},
|
||||
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
|
||||
);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
clearTimers();
|
||||
dragArmedRef.current = false;
|
||||
dragActivatedRef.current = false;
|
||||
touchStartRef.current = null;
|
||||
touchCurrentRef.current = null;
|
||||
}, [clearTimers]);
|
||||
|
||||
return {
|
||||
didLongPressRef,
|
||||
handleLongPress,
|
||||
handlePressIn,
|
||||
handleTouchMove,
|
||||
handlePressOut,
|
||||
};
|
||||
}
|
||||
@@ -95,7 +95,9 @@ async function callWorkspaceCreation({
|
||||
worktreeSlug: createNameId(),
|
||||
});
|
||||
}
|
||||
return connectedClient.openProject(input.cwd);
|
||||
return connectedClient.createWorkspace({
|
||||
source: { kind: "directory", path: input.cwd },
|
||||
});
|
||||
}
|
||||
|
||||
function failureMessageForCreationMethod(
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
|
||||
interface AgentControlOption {
|
||||
id: string;
|
||||
@@ -1486,10 +1487,13 @@ export const AgentControls = memo(function AgentControls({
|
||||
console.warn("[AgentControls] persist thinking preference failed", error);
|
||||
});
|
||||
}
|
||||
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||
console.warn("[AgentControls] setAgentThinkingOption failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
void client
|
||||
.setAgentThinkingOption(agentId, thinkingOptionId)
|
||||
.then((notice) => showProviderNoticeToast(toast, notice))
|
||||
.catch((error) => {
|
||||
console.warn("[AgentControls] setAgentThinkingOption failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[activeModelId, agentId, agentProvider, client, toast, updatePreferences],
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
|
||||
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
@@ -272,10 +273,13 @@ export const AgentModeControl = memo(function AgentModeControl({
|
||||
const handleSelectMode = useCallback(
|
||||
(modeId: string) => {
|
||||
if (!client) return;
|
||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||
console.warn("[AgentModeControl] setAgentMode failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
void client
|
||||
.setAgentMode(agentId, modeId)
|
||||
.then((notice) => showProviderNoticeToast(toast, notice))
|
||||
.catch((error) => {
|
||||
console.warn("[AgentModeControl] setAgentMode failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[agentId, client, toast],
|
||||
);
|
||||
|
||||
@@ -196,6 +196,8 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
|
||||
model: agent?.model ?? null,
|
||||
provider: agent?.provider ?? null,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -205,6 +207,8 @@ function renderContextWindowMeter(
|
||||
contextWindowUsedTokens: number | null,
|
||||
totalCostUsd: number | null,
|
||||
showPercentage: boolean,
|
||||
serverId: string,
|
||||
provider: string | null,
|
||||
): ReactElement | null {
|
||||
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
|
||||
return null;
|
||||
@@ -215,6 +219,8 @@ function renderContextWindowMeter(
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
totalCostUsd={totalCostUsd}
|
||||
showPercentage={showPercentage}
|
||||
serverId={serverId}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1643,8 +1649,17 @@ export function Composer({
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isCompactLayout,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
),
|
||||
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
|
||||
[
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isCompactLayout,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
],
|
||||
);
|
||||
const { beforeVoiceContent, footerInlineContent } = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
|
||||
import {
|
||||
applyLegacyDaemonWorkspaceOwnership,
|
||||
@@ -152,7 +153,7 @@ async function fetchWorkspaceHydrationSnapshot(input: {
|
||||
workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
|
||||
// Empty project parents only ride on the first page.
|
||||
// Project parents with no active workspaces only ride on the first page.
|
||||
for (const project of payload.emptyProjects ?? []) {
|
||||
const descriptor = normalizeEmptyProjectDescriptor(project);
|
||||
emptyProjects.set(descriptor.projectId, descriptor);
|
||||
@@ -1951,10 +1952,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
console.warn("[Session] setAgentMode skipped: daemon unavailable");
|
||||
return;
|
||||
}
|
||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||
console.error("[Session] Failed to set agent mode:", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
void client
|
||||
.setAgentMode(agentId, modeId)
|
||||
.then((notice) => showProviderNoticeToast(toast, notice))
|
||||
.catch((error) => {
|
||||
console.error("[Session] Failed to set agent mode:", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[client, toast],
|
||||
);
|
||||
@@ -1979,10 +1983,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
console.warn("[Session] setAgentThinkingOption skipped: daemon unavailable");
|
||||
return;
|
||||
}
|
||||
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||
console.error("[Session] Failed to set agent thinking option:", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
void client
|
||||
.setAgentThinkingOption(agentId, thinkingOptionId)
|
||||
.then((notice) => showProviderNoticeToast(toast, notice))
|
||||
.catch((error) => {
|
||||
console.error("[Session] Failed to set agent thinking option:", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[client, toast],
|
||||
);
|
||||
|
||||
@@ -377,7 +377,7 @@ describe("git-actions-policy", () => {
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "merge-pr-squash",
|
||||
label: "Merge",
|
||||
label: "Merge PR (squash)",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,7 +398,7 @@ describe("git-actions-policy", () => {
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "merge-pr-squash",
|
||||
label: "Merge",
|
||||
label: "Merge PR (squash)",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -458,7 +458,7 @@ describe("git-actions-policy", () => {
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "merge-pr-squash",
|
||||
label: "Merge",
|
||||
label: "Merge PR (squash)",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -512,7 +512,7 @@ describe("git-actions-policy", () => {
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "merge-pr-squash",
|
||||
label: "Merge",
|
||||
label: "Merge PR (squash)",
|
||||
});
|
||||
expect(actions.secondary.some((action) => action.id === "merge-branch")).toBe(true);
|
||||
});
|
||||
@@ -583,7 +583,7 @@ describe("git-actions-policy", () => {
|
||||
},
|
||||
{
|
||||
id: "merge-pr-squash",
|
||||
label: "Merge",
|
||||
label: "Merge PR (squash)",
|
||||
pendingLabel: "Merging PR...",
|
||||
successLabel: "PR merged",
|
||||
disabled: false,
|
||||
@@ -592,7 +592,7 @@ describe("git-actions-policy", () => {
|
||||
},
|
||||
{
|
||||
id: "merge-pr-merge",
|
||||
label: "Merge",
|
||||
label: "Merge PR (merge)",
|
||||
pendingLabel: "Merging PR...",
|
||||
successLabel: "PR merged",
|
||||
disabled: false,
|
||||
@@ -601,7 +601,7 @@ describe("git-actions-policy", () => {
|
||||
},
|
||||
{
|
||||
id: "merge-pr-rebase",
|
||||
label: "Merge",
|
||||
label: "Merge PR (rebase)",
|
||||
pendingLabel: "Merging PR...",
|
||||
successLabel: "PR merged",
|
||||
disabled: false,
|
||||
@@ -703,7 +703,10 @@ describe("git-actions-policy", () => {
|
||||
);
|
||||
|
||||
expect(oldDaemonStatus.github).toBeUndefined();
|
||||
expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Merge" });
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "merge-pr-squash",
|
||||
label: "Merge PR (squash)",
|
||||
});
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
@@ -744,7 +747,7 @@ describe("git-actions-policy", () => {
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "enable-pr-auto-merge-squash",
|
||||
label: "Auto merge",
|
||||
label: "Auto merge (squash)",
|
||||
});
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
@@ -762,6 +765,41 @@ describe("git-actions-policy", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["SQUASH", "enable-pr-auto-merge-squash", "Auto merge (squash)"],
|
||||
["MERGE", "enable-pr-auto-merge-merge", "Auto merge (merge)"],
|
||||
["REBASE", "enable-pr-auto-merge-rebase", "Auto merge (rebase)"],
|
||||
] as const)(
|
||||
"labels the %s auto-merge action with its method",
|
||||
(viewerDefaultMergeMethod, id, label) => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/993",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus({
|
||||
mergeStateStatus: "BLOCKED",
|
||||
viewerCanEnableAutoMerge: true,
|
||||
repository: {
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
viewerDefaultMergeMethod,
|
||||
},
|
||||
}),
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({ id, label });
|
||||
},
|
||||
);
|
||||
|
||||
it("does not offer auto-merge when the daemon feature gate is missing", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
@@ -851,7 +889,7 @@ describe("git-actions-policy", () => {
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "merge-pr-merge",
|
||||
label: "Merge",
|
||||
label: "Merge PR (merge)",
|
||||
});
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { OpenProjectResponseMessage } from "@getpaseo/protocol/messages";
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import type { ProjectAddResponse } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor,
|
||||
type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
type OpenProjectPayload = ProjectAddResponse["payload"];
|
||||
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
|
||||
|
||||
export interface OpenProjectSuccess {
|
||||
@@ -22,11 +24,10 @@ export interface OpenProjectDirectlyInput {
|
||||
serverId: string;
|
||||
projectPath: string;
|
||||
isConnected: boolean;
|
||||
client: Pick<DaemonClient, "openProject"> | null;
|
||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
||||
canAddProject: boolean;
|
||||
client: Pick<DaemonClient, "addProject"> | null;
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
openDraftTab: (workspaceKey: string) => string | null;
|
||||
navigateToWorkspace: (serverId: string, workspaceId: string) => void;
|
||||
}
|
||||
|
||||
export async function openProjectDirectly(
|
||||
@@ -38,8 +39,16 @@ export async function openProjectDirectly(
|
||||
return { ok: false, errorCode: null, error: null };
|
||||
}
|
||||
|
||||
const payload = await input.client.openProject(trimmedPath);
|
||||
if (payload.error || !payload.workspace) {
|
||||
if (!input.canAddProject) {
|
||||
return {
|
||||
ok: false,
|
||||
errorCode: null,
|
||||
error: "Update the host to add projects without creating a workspace.",
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await input.client.addProject(trimmedPath);
|
||||
if (payload.error || !payload.project) {
|
||||
return {
|
||||
ok: false,
|
||||
errorCode: payload.errorCode ?? null,
|
||||
@@ -47,19 +56,10 @@ export async function openProjectDirectly(
|
||||
};
|
||||
}
|
||||
|
||||
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||
input.mergeWorkspaces(normalizedServerId, [workspace]);
|
||||
input.addEmptyProject(
|
||||
normalizedServerId,
|
||||
normalizeProjectWithoutWorkspacesDescriptor(payload.project),
|
||||
);
|
||||
input.setHasHydratedWorkspaces(normalizedServerId, true);
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return { ok: false, errorCode: null, error: null };
|
||||
}
|
||||
|
||||
input.openDraftTab(workspaceKey);
|
||||
input.navigateToWorkspace(normalizedServerId, workspace.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openProjectDirectly } from "@/hooks/open-project";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const PROJECT_PATH = "/repo/project";
|
||||
|
||||
function buildWorkspacePayload() {
|
||||
function buildProjectPayload() {
|
||||
return {
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "project",
|
||||
projectRootPath: PROJECT_PATH,
|
||||
workspaceDirectory: PROJECT_PATH,
|
||||
projectKind: "git" as const,
|
||||
workspaceKind: "checkout" as const,
|
||||
name: "project",
|
||||
archivingAt: null,
|
||||
status: "done" as const,
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
interface RecordedMerge {
|
||||
interface RecordedProject {
|
||||
serverId: string;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
project: ProjectWithoutWorkspacesDescriptor;
|
||||
}
|
||||
|
||||
interface RecordedHydrated {
|
||||
@@ -34,23 +24,14 @@ interface RecordedHydrated {
|
||||
hydrated: boolean;
|
||||
}
|
||||
|
||||
interface RecordedOpenDraftTab {
|
||||
workspaceKey: string;
|
||||
}
|
||||
|
||||
interface RecordedNavigate {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function createFakeSession() {
|
||||
const merges: RecordedMerge[] = [];
|
||||
const projects: RecordedProject[] = [];
|
||||
const hydrated: RecordedHydrated[] = [];
|
||||
return {
|
||||
merges,
|
||||
projects,
|
||||
hydrated,
|
||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
|
||||
merges.push({ serverId, workspaces: Array.from(workspaces) });
|
||||
addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => {
|
||||
projects.push({ serverId, project });
|
||||
},
|
||||
setHasHydratedWorkspaces: (serverId: string, value: boolean) => {
|
||||
hydrated.push({ serverId, hydrated: value });
|
||||
@@ -58,86 +39,88 @@ function createFakeSession() {
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeWorkspaceLayout() {
|
||||
const openedTabs: RecordedOpenDraftTab[] = [];
|
||||
return {
|
||||
openedTabs,
|
||||
openDraftTab: (workspaceKey: string) => {
|
||||
openedTabs.push({ workspaceKey });
|
||||
return "tab-1";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeNavigator() {
|
||||
const navigations: RecordedNavigate[] = [];
|
||||
return {
|
||||
navigations,
|
||||
navigateToWorkspace: (serverId: string, workspaceId: string) => {
|
||||
navigations.push({ serverId, workspaceId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("openProjectDirectly", () => {
|
||||
it("opens the workspace, marks workspaces hydrated, and seeds a draft tab", async () => {
|
||||
it("adds the project and marks workspaces hydrated without opening a workspace", async () => {
|
||||
const session = createFakeSession();
|
||||
const layout = createFakeWorkspaceLayout();
|
||||
const navigator = createFakeNavigator();
|
||||
const workspacePayload = buildWorkspacePayload();
|
||||
const projectPayload = buildProjectPayload();
|
||||
|
||||
const result = await openProjectDirectly({
|
||||
serverId: SERVER_ID,
|
||||
projectPath: PROJECT_PATH,
|
||||
isConnected: true,
|
||||
canAddProject: true,
|
||||
client: {
|
||||
openProject: async () => ({
|
||||
addProject: async () => ({
|
||||
requestId: "request-1",
|
||||
error: null,
|
||||
workspace: workspacePayload,
|
||||
project: projectPayload,
|
||||
}),
|
||||
},
|
||||
mergeWorkspaces: session.mergeWorkspaces,
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
openDraftTab: layout.openDraftTab,
|
||||
navigateToWorkspace: navigator.navigateToWorkspace,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(session.merges).toHaveLength(1);
|
||||
expect(session.merges[0]?.serverId).toBe(SERVER_ID);
|
||||
expect(session.merges[0]?.workspaces[0]).toMatchObject({
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectRootPath: PROJECT_PATH,
|
||||
workspaceDirectory: PROJECT_PATH,
|
||||
});
|
||||
expect(session.projects).toEqual([
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
project: {
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "project",
|
||||
projectCustomName: null,
|
||||
projectKind: "git",
|
||||
projectRootPath: PROJECT_PATH,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(session.hydrated).toEqual([{ serverId: SERVER_ID, hydrated: true }]);
|
||||
expect(layout.openedTabs).toEqual([{ workspaceKey: `${SERVER_ID}:1` }]);
|
||||
expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: "1" }]);
|
||||
});
|
||||
|
||||
it("does not navigate or seed tabs when openProject fails", async () => {
|
||||
it("fails before sending when the host does not support adding projects without workspaces", async () => {
|
||||
const session = createFakeSession();
|
||||
const result = await openProjectDirectly({
|
||||
serverId: SERVER_ID,
|
||||
projectPath: PROJECT_PATH,
|
||||
isConnected: true,
|
||||
canAddProject: false,
|
||||
client: {
|
||||
addProject: async () => ({
|
||||
requestId: "request-unsupported",
|
||||
error: null,
|
||||
project: buildProjectPayload(),
|
||||
}),
|
||||
},
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errorCode: null,
|
||||
error: "Update the host to add projects without creating a workspace.",
|
||||
});
|
||||
expect(session.projects).toEqual([]);
|
||||
expect(session.hydrated).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not add a project when addProject fails", async () => {
|
||||
const session = createFakeSession();
|
||||
const layout = createFakeWorkspaceLayout();
|
||||
const navigator = createFakeNavigator();
|
||||
|
||||
const result = await openProjectDirectly({
|
||||
serverId: SERVER_ID,
|
||||
projectPath: PROJECT_PATH,
|
||||
isConnected: true,
|
||||
canAddProject: true,
|
||||
client: {
|
||||
openProject: async () => ({
|
||||
addProject: async () => ({
|
||||
requestId: "request-2",
|
||||
error: "Directory not found: /repo/project",
|
||||
errorCode: "directory_not_found" as const,
|
||||
workspace: null,
|
||||
project: null,
|
||||
}),
|
||||
},
|
||||
mergeWorkspaces: session.mergeWorkspaces,
|
||||
addEmptyProject: session.addEmptyProject,
|
||||
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
|
||||
openDraftTab: layout.openDraftTab,
|
||||
navigateToWorkspace: navigator.navigateToWorkspace,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -145,9 +128,7 @@ describe("openProjectDirectly", () => {
|
||||
errorCode: "directory_not_found",
|
||||
error: "Directory not found: /repo/project",
|
||||
});
|
||||
expect(session.merges).toEqual([]);
|
||||
expect(session.projects).toEqual([]);
|
||||
expect(session.hydrated).toEqual([]);
|
||||
expect(layout.openedTabs).toEqual([]);
|
||||
expect(navigator.navigations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
|
||||
|
||||
export function useOpenProject(
|
||||
@@ -12,7 +9,12 @@ export function useOpenProject(
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const canAddProject = useSessionStore((state) =>
|
||||
normalizedServerId
|
||||
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
|
||||
: false,
|
||||
);
|
||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
@@ -21,17 +23,19 @@ export function useOpenProject(
|
||||
serverId: normalizedServerId,
|
||||
projectPath: path,
|
||||
isConnected,
|
||||
canAddProject,
|
||||
client,
|
||||
mergeWorkspaces,
|
||||
addEmptyProject,
|
||||
setHasHydratedWorkspaces,
|
||||
openDraftTab: (workspaceKey: string) =>
|
||||
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, {
|
||||
kind: "draft",
|
||||
draftId: generateDraftId(),
|
||||
}),
|
||||
navigateToWorkspace,
|
||||
});
|
||||
},
|
||||
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
|
||||
[
|
||||
addEmptyProject,
|
||||
canAddProject,
|
||||
client,
|
||||
isConnected,
|
||||
normalizedServerId,
|
||||
setHasHydratedWorkspaces,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -604,16 +604,16 @@ export const ar: TranslationResources = {
|
||||
success: "مؤرشف",
|
||||
},
|
||||
mergePr: {
|
||||
squash: "دمج",
|
||||
merge: "دمج",
|
||||
rebase: "دمج",
|
||||
squash: "دمج PR (squash)",
|
||||
merge: "دمج PR (merge)",
|
||||
rebase: "دمج PR (rebase)",
|
||||
pending: "دمج PR...",
|
||||
success: "تم دمج PR",
|
||||
},
|
||||
autoMerge: {
|
||||
enableSquash: "دمج تلقائي",
|
||||
enableMerge: "دمج تلقائي",
|
||||
enableRebase: "دمج تلقائي",
|
||||
enableSquash: "دمج تلقائي (squash)",
|
||||
enableMerge: "دمج تلقائي (merge)",
|
||||
enableRebase: "دمج تلقائي (rebase)",
|
||||
enabled: "تم تمكين الدمج التلقائي",
|
||||
enabling: "تمكين الدمج التلقائي...",
|
||||
disabling: "تعطيل الدمج التلقائي...",
|
||||
@@ -1307,6 +1307,8 @@ export const ar: TranslationResources = {
|
||||
backdrop: "خلفية القائمة",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "فصل {{label}}",
|
||||
detachTooltip: "فصل الوكيل الفرعي",
|
||||
archiveAction: "أرشيف{{label}}",
|
||||
archiveTooltip: "أرشفة الوكيل الفرعي",
|
||||
},
|
||||
@@ -1392,6 +1394,7 @@ export const ar: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "مقدمي الخدمات",
|
||||
usage: "الاستخدام",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
@@ -1778,6 +1781,9 @@ export const ar: TranslationResources = {
|
||||
button: "التشخيص",
|
||||
refresh: "ينعش",
|
||||
refreshing: "منعش...",
|
||||
copyLabel: "التشخيص",
|
||||
copyAccessibility: "نسخ التشخيص",
|
||||
copyFailed: "فشل نسخ التشخيص",
|
||||
refreshAccessibility: "تحديث التشخيص",
|
||||
refreshingAccessibility: "تحديث التشخيص",
|
||||
running: "تشغيل التشخيص...",
|
||||
|
||||
@@ -603,16 +603,16 @@ export const en = {
|
||||
success: "Archived",
|
||||
},
|
||||
mergePr: {
|
||||
squash: "Merge",
|
||||
merge: "Merge",
|
||||
rebase: "Merge",
|
||||
squash: "Merge PR (squash)",
|
||||
merge: "Merge PR (merge)",
|
||||
rebase: "Merge PR (rebase)",
|
||||
pending: "Merging PR...",
|
||||
success: "PR merged",
|
||||
},
|
||||
autoMerge: {
|
||||
enableSquash: "Auto merge",
|
||||
enableMerge: "Auto merge",
|
||||
enableRebase: "Auto merge",
|
||||
enableSquash: "Auto merge (squash)",
|
||||
enableMerge: "Auto merge (merge)",
|
||||
enableRebase: "Auto merge (rebase)",
|
||||
enabled: "Auto-merge enabled",
|
||||
enabling: "Enabling auto-merge...",
|
||||
disabling: "Disabling auto-merge...",
|
||||
@@ -1314,6 +1314,8 @@ export const en = {
|
||||
backdrop: "Menu backdrop",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Detach {{label}}",
|
||||
detachTooltip: "Detach subagent",
|
||||
archiveAction: "Archive {{label}}",
|
||||
archiveTooltip: "Archive subagent",
|
||||
},
|
||||
@@ -1399,6 +1401,7 @@ export const en = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "Usage",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
@@ -1786,6 +1789,9 @@ export const en = {
|
||||
button: "Diagnostic",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
running: "Running diagnostic...",
|
||||
|
||||
@@ -610,16 +610,16 @@ export const es: TranslationResources = {
|
||||
success: "Archivado",
|
||||
},
|
||||
mergePr: {
|
||||
squash: "Fusionar",
|
||||
merge: "Fusionar",
|
||||
rebase: "Fusionar",
|
||||
pending: "FusionandoPR...",
|
||||
success: "PRfusionado",
|
||||
squash: "Fusionar PR (squash)",
|
||||
merge: "Fusionar PR (merge)",
|
||||
rebase: "Fusionar PR (rebase)",
|
||||
pending: "Fusionando PR...",
|
||||
success: "PR fusionado",
|
||||
},
|
||||
autoMerge: {
|
||||
enableSquash: "Fusión automática",
|
||||
enableMerge: "Fusión automática",
|
||||
enableRebase: "Fusión automática",
|
||||
enableSquash: "Fusión automática (squash)",
|
||||
enableMerge: "Fusión automática (merge)",
|
||||
enableRebase: "Fusión automática (rebase)",
|
||||
enabled: "Combinación automática habilitada",
|
||||
enabling: "Habilitando la fusión automática...",
|
||||
disabling: "Desactivando la fusión automática...",
|
||||
@@ -1343,6 +1343,8 @@ export const es: TranslationResources = {
|
||||
backdrop: "Fondo del menú",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Separar {{label}}",
|
||||
detachTooltip: "Separar subagente",
|
||||
archiveAction: "Archivo{{label}}",
|
||||
archiveTooltip: "Subagente de archivo",
|
||||
},
|
||||
@@ -1428,6 +1430,7 @@ export const es: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Proveedores",
|
||||
usage: "Uso",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
@@ -1819,6 +1822,9 @@ export const es: TranslationResources = {
|
||||
button: "Diagnóstico",
|
||||
refresh: "Refrescar",
|
||||
refreshing: "Refrescante...",
|
||||
copyLabel: "diagnóstico",
|
||||
copyAccessibility: "Copiar diagnóstico",
|
||||
copyFailed: "No se pudo copiar el diagnóstico",
|
||||
refreshAccessibility: "Actualizar diagnóstico",
|
||||
refreshingAccessibility: "Diagnóstico refrescante",
|
||||
running: "Ejecutando diagnóstico...",
|
||||
|
||||
@@ -610,16 +610,16 @@ export const fr: TranslationResources = {
|
||||
success: "Archivé",
|
||||
},
|
||||
mergePr: {
|
||||
squash: "Fusionner",
|
||||
merge: "Fusionner",
|
||||
rebase: "Fusionner",
|
||||
pending: "Fusion dePR...",
|
||||
success: "PRfusionné",
|
||||
squash: "Fusionner PR (squash)",
|
||||
merge: "Fusionner PR (merge)",
|
||||
rebase: "Fusionner PR (rebase)",
|
||||
pending: "Fusion de PR...",
|
||||
success: "PR fusionné",
|
||||
},
|
||||
autoMerge: {
|
||||
enableSquash: "Fusion automatique",
|
||||
enableMerge: "Fusion automatique",
|
||||
enableRebase: "Fusion automatique",
|
||||
enableSquash: "Fusion automatique (squash)",
|
||||
enableMerge: "Fusion automatique (merge)",
|
||||
enableRebase: "Fusion automatique (rebase)",
|
||||
enabled: "Fusion automatique activée",
|
||||
enabling: "Activation de la fusion automatique...",
|
||||
disabling: "Désactivation de la fusion automatique...",
|
||||
@@ -1346,6 +1346,8 @@ export const fr: TranslationResources = {
|
||||
backdrop: "Toile de fond du menu",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Detacher {{label}}",
|
||||
detachTooltip: "Detacher le sous-agent",
|
||||
archiveAction: "Archiver{{label}}",
|
||||
archiveTooltip: "Sous-agent d'archivage",
|
||||
},
|
||||
@@ -1431,6 +1433,7 @@ export const fr: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Fournisseurs",
|
||||
usage: "Utilisation",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
@@ -1824,6 +1827,9 @@ export const fr: TranslationResources = {
|
||||
button: "Diagnostique",
|
||||
refresh: "Rafraîchir",
|
||||
refreshing: "Rafraîchissant...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copier le diagnostic",
|
||||
copyFailed: "Échec de la copie du diagnostic",
|
||||
refreshAccessibility: "Actualiser le diagnostic",
|
||||
refreshingAccessibility: "Diagnostic rafraîchissant",
|
||||
running: "Exécution du diagnostic...",
|
||||
|
||||
@@ -609,16 +609,16 @@ export const ru: TranslationResources = {
|
||||
success: "В архиве",
|
||||
},
|
||||
mergePr: {
|
||||
squash: "Объединить",
|
||||
merge: "Объединить",
|
||||
rebase: "Объединить",
|
||||
squash: "Объединить PR (squash)",
|
||||
merge: "Объединить PR (merge)",
|
||||
rebase: "Объединить PR (rebase)",
|
||||
pending: "Объединение PR...",
|
||||
success: "PR объединен",
|
||||
},
|
||||
autoMerge: {
|
||||
enableSquash: "Автообъединение",
|
||||
enableMerge: "Автообъединение",
|
||||
enableRebase: "Автообъединение",
|
||||
enableSquash: "Автообъединение (squash)",
|
||||
enableMerge: "Автообъединение (merge)",
|
||||
enableRebase: "Автообъединение (rebase)",
|
||||
enabled: "Автоматическое объединение включено",
|
||||
enabling: "Включение автоматического объединения...",
|
||||
disabling: "Отключение автоматического объединения...",
|
||||
@@ -1335,6 +1335,8 @@ export const ru: TranslationResources = {
|
||||
backdrop: "Фон меню",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Отсоединить {{label}}",
|
||||
detachTooltip: "Отсоединить субагент",
|
||||
archiveAction: "Архив{{label}}",
|
||||
archiveTooltip: "Архивный субагент",
|
||||
},
|
||||
@@ -1420,6 +1422,7 @@ export const ru: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Провайдеры",
|
||||
usage: "Использование",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
@@ -1810,6 +1813,9 @@ export const ru: TranslationResources = {
|
||||
button: "Диагностика",
|
||||
refresh: "Обновить",
|
||||
refreshing: "Освежающий...",
|
||||
copyLabel: "диагностика",
|
||||
copyAccessibility: "Скопировать диагностику",
|
||||
copyFailed: "Не удалось скопировать диагностику",
|
||||
refreshAccessibility: "Обновить диагностику",
|
||||
refreshingAccessibility: "Обновление диагностики",
|
||||
running: "Запускаю диагностику...",
|
||||
|
||||
@@ -602,16 +602,16 @@ export const zhCN: TranslationResources = {
|
||||
success: "已归档",
|
||||
},
|
||||
mergePr: {
|
||||
squash: "Merge",
|
||||
merge: "Merge",
|
||||
rebase: "Merge",
|
||||
squash: "Merge PR (squash)",
|
||||
merge: "Merge PR (merge)",
|
||||
rebase: "Merge PR (rebase)",
|
||||
pending: "正在 merge PR...",
|
||||
success: "PR 已 merge",
|
||||
},
|
||||
autoMerge: {
|
||||
enableSquash: "Auto merge",
|
||||
enableMerge: "Auto merge",
|
||||
enableRebase: "Auto merge",
|
||||
enableSquash: "Auto merge (squash)",
|
||||
enableMerge: "Auto merge (merge)",
|
||||
enableRebase: "Auto merge (rebase)",
|
||||
enabled: "Auto-merge 已启用",
|
||||
enabling: "正在启用 auto-merge...",
|
||||
disabling: "正在禁用 auto-merge...",
|
||||
@@ -1290,6 +1290,8 @@ export const zhCN: TranslationResources = {
|
||||
backdrop: "菜单背景",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "分离 {{label}}",
|
||||
detachTooltip: "分离 subagent",
|
||||
archiveAction: "归档 {{label}}",
|
||||
archiveTooltip: "归档 subagent",
|
||||
},
|
||||
@@ -1375,6 +1377,7 @@ export const zhCN: TranslationResources = {
|
||||
agents: "Agents",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "使用情况",
|
||||
terminals: "Terminals",
|
||||
host: "Host",
|
||||
},
|
||||
@@ -1755,6 +1758,9 @@ export const zhCN: TranslationResources = {
|
||||
button: "诊断",
|
||||
refresh: "刷新",
|
||||
refreshing: "正在刷新...",
|
||||
copyLabel: "诊断",
|
||||
copyAccessibility: "复制诊断",
|
||||
copyFailed: "复制诊断失败",
|
||||
refreshAccessibility: "刷新诊断",
|
||||
refreshingAccessibility: "正在刷新诊断",
|
||||
running: "正在运行诊断...",
|
||||
|
||||
@@ -77,7 +77,7 @@ import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useArchiveSubagent, useSubagentsForParent } from "@/subagents";
|
||||
import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -1442,6 +1442,9 @@ function ActiveAgentComposer({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
});
|
||||
const canDetachSubagents = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
||||
);
|
||||
const handleOpenSubagent = useCallback(
|
||||
(subagentId: string) => {
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
@@ -1449,6 +1452,7 @@ function ActiveAgentComposer({
|
||||
[serverId],
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -1545,6 +1549,7 @@ function ActiveAgentComposer({
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onArchiveSubagent={handleArchiveSubagent}
|
||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||
/>
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct, formatAmount, formatResetLabel } from "./format";
|
||||
import type { ProviderUsageBalance, ProviderUsageTone } from "./types";
|
||||
|
||||
interface ResolvedBalance {
|
||||
amountText: string;
|
||||
usedPct: number | null;
|
||||
}
|
||||
|
||||
function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
|
||||
const { used, remaining, limit, unit } = balance;
|
||||
if (limit != null && limit > 0) {
|
||||
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
|
||||
const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null;
|
||||
const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—";
|
||||
return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct };
|
||||
}
|
||||
if (remaining != null) {
|
||||
return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null };
|
||||
}
|
||||
if (used != null) {
|
||||
return { amountText: formatAmount(used, unit), usedPct: null };
|
||||
}
|
||||
return { amountText: "—", usedPct: null };
|
||||
}
|
||||
|
||||
function fillToneStyle(tone: ProviderUsageTone) {
|
||||
switch (tone) {
|
||||
case "ok":
|
||||
return styles.fillOk;
|
||||
case "warning":
|
||||
return styles.fillWarning;
|
||||
case "danger":
|
||||
return styles.fillDanger;
|
||||
default:
|
||||
return styles.fillDefault;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) {
|
||||
const { amountText, usedPct } = resolveBalance(balance);
|
||||
const tone = balance.tone ?? "default";
|
||||
const resetLabel = formatResetLabel(balance.resetsAt);
|
||||
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
|
||||
[usedPct, tone],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{balance.label}
|
||||
</Text>
|
||||
<Text style={styles.value}>
|
||||
{amountText}
|
||||
{resetLabel ? <Text style={styles.reset}>{` · ${resetLabel}`}</Text> : null}
|
||||
</Text>
|
||||
</View>
|
||||
{usedPct != null ? (
|
||||
<View style={styles.track}>
|
||||
<View style={fillStyle} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: 3,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
value: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
reset: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
track: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
fillDefault: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
fillOk: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
fillWarning: {
|
||||
backgroundColor: theme.colors.statusWarning,
|
||||
},
|
||||
fillDanger: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ProviderUsageBalanceBar } from "./balance-bar";
|
||||
import { formatAgo } from "./format";
|
||||
import type { ProviderUsage } from "./types";
|
||||
import { ProviderUsageWindowBar } from "./window-bar";
|
||||
|
||||
interface ProviderUsageIconProps {
|
||||
iconKey: string;
|
||||
size: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
|
||||
const Icon = getProviderIcon(iconKey);
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
|
||||
|
||||
const foregroundIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
function statusText(usage: ProviderUsage): string | null {
|
||||
if (usage.status === "available") return null;
|
||||
return usage.status === "error" ? "Error" : "Unavailable";
|
||||
}
|
||||
|
||||
function footerText(usage: ProviderUsage): string | null {
|
||||
const updated = formatAgo(usage.fetchedAt);
|
||||
const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
|
||||
(part): part is string => typeof part === "string" && part.length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
export function ProviderUsageCard({
|
||||
usage,
|
||||
compact = false,
|
||||
}: {
|
||||
usage: ProviderUsage;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const status = statusText(usage);
|
||||
const footer = footerText(usage);
|
||||
const balances = usage.balances ?? [];
|
||||
const details = usage.details ?? [];
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
[compact],
|
||||
);
|
||||
const dotStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDot,
|
||||
usage.status === "available" && styles.statusDotAvailable,
|
||||
usage.status === "error" && styles.statusDotError,
|
||||
],
|
||||
[usage.status],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={styles.header}>
|
||||
<ThemedProviderUsageIcon
|
||||
iconKey={usage.providerId}
|
||||
size={16}
|
||||
uniProps={foregroundIconColor}
|
||||
/>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{usage.displayName}
|
||||
</Text>
|
||||
{usage.planLabel ? <StatusBadge label={usage.planLabel} variant="muted" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
<View style={dotStyle} />
|
||||
<Text style={styles.statusLabel}>{status}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{usage.error ? (
|
||||
<Text style={styles.error} numberOfLines={3}>
|
||||
{usage.error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{usage.windows.length > 0 || balances.length > 0 ? (
|
||||
<View style={styles.bars}>
|
||||
{usage.windows.map((window) => (
|
||||
<ProviderUsageWindowBar key={window.id} window={window} />
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
<ProviderUsageBalanceBar key={balance.id} balance={balance} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{details.length > 0 ? (
|
||||
<View style={styles.details}>
|
||||
{details.map((detail) => (
|
||||
<View key={detail.id} style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel} numberOfLines={1}>
|
||||
{detail.label}
|
||||
</Text>
|
||||
<Text style={styles.detailValue} numberOfLines={1}>
|
||||
{detail.value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{footer ? (
|
||||
<Text style={styles.footer} numberOfLines={1}>
|
||||
{footer}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
containerPadded: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
containerCompact: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
name: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
headerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
statusDotAvailable: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
statusDotError: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
statusLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
bars: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
details: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
detailLabel: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
detailValue: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
footer: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
// User-facing copy for the provider-usage surfaces, centralized so localization
|
||||
// is a single-file change. INTEGRATION: move these into the i18n resources
|
||||
// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the
|
||||
// call sites once the feature is wired to data. Kept inline for now because the
|
||||
// surfaces are not yet mounted and the locale files are being edited elsewhere.
|
||||
export const providerUsageCopy = {
|
||||
title: "Plan usage",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing...",
|
||||
loading: "Loading usage...",
|
||||
empty: "No usage data",
|
||||
errorTitle: "Unable to load usage",
|
||||
hostUnavailable: "Connect to this host to see provider usage",
|
||||
hostUpgradeRequired: "Update the host to see provider usage",
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
retry: "Try again",
|
||||
tooltipLoading: "Loading plan usage…",
|
||||
} as const;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { formatTokenCount } from "@/components/context-window-meter.utils";
|
||||
import type { ProviderUsageBalanceUnit } from "./types";
|
||||
|
||||
export function clampPct(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
export function formatPct(value: number): string {
|
||||
return `${Math.round(clampPct(value))}%`;
|
||||
}
|
||||
|
||||
function relativeDuration(iso: string): string | null {
|
||||
const diffMs = new Date(iso).getTime() - Date.now();
|
||||
if (!Number.isFinite(diffMs)) return null;
|
||||
if (diffMs <= 0) return "now";
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays > 0) return `${diffDays}d`;
|
||||
if (diffHours > 0) return `${diffHours}h`;
|
||||
return `${diffMinutes}m`;
|
||||
}
|
||||
|
||||
export function formatResetLabel(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const rel = relativeDuration(iso);
|
||||
if (!rel) return null;
|
||||
return rel === "now" ? "resetting now" : `resets ${rel}`;
|
||||
}
|
||||
|
||||
export function formatAgo(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(diffMs)) return null;
|
||||
if (diffMs < 60_000) return "just now";
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays > 0) return `${diffDays}d ago`;
|
||||
if (diffHours > 0) return `${diffHours}h ago`;
|
||||
return `${diffMinutes}m ago`;
|
||||
}
|
||||
|
||||
export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string {
|
||||
switch (unit) {
|
||||
case "usd":
|
||||
return `$${value.toFixed(2)}`;
|
||||
case "tokens":
|
||||
return formatTokenCount(value);
|
||||
default:
|
||||
return value.toLocaleString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Fragment } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ProviderUsageCard } from "./card";
|
||||
import type { ProviderUsage } from "./types";
|
||||
|
||||
export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
{providers.map((usage, index) => (
|
||||
<Fragment key={usage.providerId}>
|
||||
{index > 0 ? <View style={styles.divider} /> : null}
|
||||
<ProviderUsageCard usage={usage} />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,96 @@
|
||||
import { RefreshCw } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import { ProviderUsageList } from "./list";
|
||||
import type { ProviderUsageView } from "./types";
|
||||
|
||||
export function ProviderUsageSettingsSection({
|
||||
view,
|
||||
onRefresh,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
|
||||
|
||||
const refreshButton = useMemo(
|
||||
() => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={RefreshCw}
|
||||
loading={busy}
|
||||
onPress={onRefresh}
|
||||
accessibilityLabel={providerUsageCopy.refresh}
|
||||
>
|
||||
{busy ? providerUsageCopy.refreshing : providerUsageCopy.refresh}
|
||||
</Button>
|
||||
),
|
||||
[busy, onRefresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={providerUsageCopy.title}
|
||||
testID="provider-usage-card"
|
||||
trailing={refreshButton}
|
||||
>
|
||||
<ProviderUsageBody view={view} onRefresh={onRefresh} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderUsageBody({
|
||||
view,
|
||||
onRefresh,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
if (view.kind === "loading") {
|
||||
return (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>{providerUsageCopy.loading}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<Alert variant="error" title={providerUsageCopy.errorTitle} description={view.message}>
|
||||
<Button variant="outline" size="sm" onPress={onRefresh}>
|
||||
{providerUsageCopy.retry}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.payload.providers.length === 0) {
|
||||
return (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>{providerUsageCopy.empty}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProviderUsageList providers={view.payload.providers} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ProviderUsageTone } from "./types";
|
||||
|
||||
export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone {
|
||||
if (usedPct == null) return "default";
|
||||
if (usedPct > 90) return "danger";
|
||||
if (usedPct >= 70) return "warning";
|
||||
return "default";
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ProviderUsageCard } from "./card";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import type { ProviderUsage, ProviderUsageView } from "./types";
|
||||
|
||||
function matchProvider(
|
||||
providers: ProviderUsage[],
|
||||
activeProviderId: string | null | undefined,
|
||||
): ProviderUsage | null {
|
||||
if (!activeProviderId) return null;
|
||||
const target = activeProviderId.toLowerCase();
|
||||
return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null;
|
||||
}
|
||||
|
||||
// Renders the active agent's provider usage inside the context-meter tooltip.
|
||||
// Returns nothing when the active provider has no usage entry, so the meter's
|
||||
// own context section stays the whole tooltip.
|
||||
export function ProviderUsageTooltipSection({
|
||||
view,
|
||||
activeProviderId,
|
||||
}: {
|
||||
view: ProviderUsageView;
|
||||
activeProviderId: string | null | undefined;
|
||||
}) {
|
||||
if (view.kind === "loading") {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.detail}>{providerUsageCopy.tooltipLoading}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (view.kind === "error") {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<Text style={styles.error}>{view.message}</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const usage = matchProvider(view.payload.providers, activeProviderId);
|
||||
if (!usage) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
<ProviderUsageCard usage={usage} compact />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[1] + 1,
|
||||
},
|
||||
detail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: theme.fontSize.xs * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,26 @@
|
||||
import type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageListResponseMessage,
|
||||
ProviderUsageStatus,
|
||||
ProviderUsageTone,
|
||||
ProviderUsageWindow,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
export type {
|
||||
ProviderUsage,
|
||||
ProviderUsageBalance,
|
||||
ProviderUsageDetail,
|
||||
ProviderUsageStatus,
|
||||
ProviderUsageTone,
|
||||
ProviderUsageWindow,
|
||||
};
|
||||
|
||||
export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"];
|
||||
export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||
|
||||
export type ProviderUsageView =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean };
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import type { ProviderUsageListPayload, ProviderUsageView } from "./types";
|
||||
|
||||
export const PROVIDER_USAGE_STALE_TIME_MS = 5 * 60 * 1000;
|
||||
|
||||
type ProviderUsageClient = Pick<DaemonClient, "listProviderUsage">;
|
||||
|
||||
export function providerUsageQueryKey(serverId: string | null | undefined) {
|
||||
return ["providerUsage", serverId ?? ""] as const;
|
||||
}
|
||||
|
||||
async function fetchProviderUsage(client: ProviderUsageClient): Promise<ProviderUsageListPayload> {
|
||||
return client.listProviderUsage();
|
||||
}
|
||||
|
||||
interface UseProviderUsageOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useProviderUsage(
|
||||
serverId: string | null | undefined,
|
||||
options: UseProviderUsageOptions = {},
|
||||
): {
|
||||
view: ProviderUsageView;
|
||||
refresh: () => Promise<void>;
|
||||
canFetch: boolean;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const supportsProviderUsage = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true,
|
||||
);
|
||||
const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]);
|
||||
const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage);
|
||||
const enabled = Boolean((options.enabled ?? true) && canFetch);
|
||||
|
||||
const queryFn = useCallback(async () => {
|
||||
if (!client) {
|
||||
throw new Error(providerUsageCopy.clientUnavailable);
|
||||
}
|
||||
return fetchProviderUsage(client);
|
||||
}, [client]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled,
|
||||
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
if (!canFetch) {
|
||||
return;
|
||||
}
|
||||
await queryClient.fetchQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
staleTime: PROVIDER_USAGE_STALE_TIME_MS,
|
||||
});
|
||||
}, [canFetch, queryClient, queryFn, queryKey]);
|
||||
|
||||
const view = useMemo<ProviderUsageView>(() => {
|
||||
if (!serverId || !client || !isConnected) {
|
||||
return { kind: "error", message: providerUsageCopy.hostUnavailable };
|
||||
}
|
||||
if (!supportsProviderUsage) {
|
||||
return { kind: "error", message: providerUsageCopy.hostUpgradeRequired };
|
||||
}
|
||||
if (query.data) {
|
||||
return {
|
||||
kind: "ready",
|
||||
payload: query.data,
|
||||
isRefreshing: query.isFetching,
|
||||
};
|
||||
}
|
||||
if (query.isError) {
|
||||
return {
|
||||
kind: "error",
|
||||
message: query.error instanceof Error ? query.error.message : String(query.error),
|
||||
};
|
||||
}
|
||||
return { kind: "loading" };
|
||||
}, [
|
||||
client,
|
||||
isConnected,
|
||||
query.data,
|
||||
query.error,
|
||||
query.isError,
|
||||
query.isFetching,
|
||||
serverId,
|
||||
supportsProviderUsage,
|
||||
]);
|
||||
|
||||
return { view, refresh, canFetch };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { clampPct, formatPct, formatResetLabel } from "./format";
|
||||
import { deriveTone } from "./tone";
|
||||
import type { ProviderUsageTone, ProviderUsageWindow } from "./types";
|
||||
|
||||
function resolveUsedPct(window: ProviderUsageWindow): number | null {
|
||||
if (window.usedPct != null) return window.usedPct;
|
||||
if (window.remainingPct != null) return 100 - window.remainingPct;
|
||||
return null;
|
||||
}
|
||||
|
||||
function fillToneStyle(tone: ProviderUsageTone) {
|
||||
switch (tone) {
|
||||
case "ok":
|
||||
return styles.fillOk;
|
||||
case "warning":
|
||||
return styles.fillWarning;
|
||||
case "danger":
|
||||
return styles.fillDanger;
|
||||
default:
|
||||
return styles.fillDefault;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) {
|
||||
const usedPct = resolveUsedPct(window);
|
||||
const tone = window.tone ?? deriveTone(usedPct);
|
||||
|
||||
const fillWidth = clampPct(usedPct ?? 0);
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }],
|
||||
[fillWidth, tone],
|
||||
);
|
||||
|
||||
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
|
||||
const trailing = isAtRisk
|
||||
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
|
||||
: formatResetLabel(window.resetsAt);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{window.label}
|
||||
</Text>
|
||||
<Text style={styles.value}>
|
||||
{usedPct != null ? formatPct(usedPct) : "—"}
|
||||
{trailing ? (
|
||||
<Text style={isAtRisk ? styles.atRisk : styles.reset}>{` · ${trailing}`}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.track}>
|
||||
<View style={fillStyle} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: 3,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
value: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
reset: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
atRisk: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
track: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
fill: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
fillDefault: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
fillOk: {
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
},
|
||||
fillWarning: {
|
||||
backgroundColor: theme.colors.statusWarning,
|
||||
},
|
||||
fillDanger: {
|
||||
backgroundColor: theme.colors.statusDanger,
|
||||
},
|
||||
}));
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Network,
|
||||
Bot,
|
||||
Boxes,
|
||||
Gauge,
|
||||
Keyboard,
|
||||
Stethoscope,
|
||||
Info,
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
HostAgentsPage,
|
||||
HostSettingsPage,
|
||||
HostProvidersPage,
|
||||
HostUsagePage,
|
||||
HostWorkspacesPage,
|
||||
HostTerminalsPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
@@ -171,6 +173,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
|
||||
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
|
||||
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
||||
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
|
||||
{ id: "terminals", labelKey: "settings.hostSections.terminals", icon: SquareTerminal },
|
||||
{ id: "host", labelKey: "settings.hostSections.host", icon: Server },
|
||||
];
|
||||
@@ -188,6 +191,8 @@ function renderHostSettingsContent(
|
||||
return <HostWorkspacesPage serverId={view.serverId} />;
|
||||
case "providers":
|
||||
return <HostProvidersPage serverId={view.serverId} />;
|
||||
case "usage":
|
||||
return <HostUsagePage serverId={view.serverId} />;
|
||||
case "terminals":
|
||||
return <HostTerminalsPage serverId={view.serverId} />;
|
||||
case "host":
|
||||
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { ProvidersSection } from "@/screens/settings/providers-section";
|
||||
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
|
||||
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -306,6 +308,24 @@ export function HostProvidersPage({ serverId }: { serverId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function HostUsagePage({ serverId }: { serverId: string }) {
|
||||
const host = useHostProfile(serverId);
|
||||
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId);
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refreshProviderUsage();
|
||||
}, [refreshProviderUsage]);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ProviderUsageSettingsSection view={providerUsageView} onRefresh={handleRefresh} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostSettingsPage({
|
||||
serverId,
|
||||
onHostRemoved,
|
||||
|
||||
@@ -231,7 +231,7 @@ describe("workspace structure composition", () => {
|
||||
tracked.stop();
|
||||
});
|
||||
|
||||
it("renders a project with zero active workspaces as an empty project parent", () => {
|
||||
it("renders a project parent with zero active workspaces", () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map());
|
||||
useSessionStore.getState().setEmptyProjects(SERVER_ID, [
|
||||
|
||||
@@ -331,8 +331,8 @@ export interface SessionState {
|
||||
agents: Map<string, Agent>;
|
||||
agentDetails: Map<string, Agent>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
// Project parents with no active workspaces, keyed by projectId. Rendered as
|
||||
// empty project rows in the sidebar.
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
// `emptyProjects` name is the existing protocol/store projection.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
// Transient restore state for archived workspaces, keyed by normalized
|
||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
type WorkspaceAgentVisibility,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { selectSubagentsForParent } from "@/subagents";
|
||||
import { selectSubagentsForParent } from "@/subagents/select";
|
||||
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useSessionStore, type Agent } from "./session-store";
|
||||
|
||||
@@ -167,4 +167,52 @@ describe("workspace subagents integration", () => {
|
||||
).map((row) => row.id),
|
||||
).toEqual(["child-agent"]);
|
||||
});
|
||||
|
||||
it("moves a detached child out of the parent section and back into normal workspace tabs", () => {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
expect(workspaceKey).toBeTruthy();
|
||||
|
||||
const parent = makeAgent({
|
||||
id: "parent-agent",
|
||||
title: "Parent agent",
|
||||
});
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
parentAgentId: "parent-agent",
|
||||
title: "Child agent",
|
||||
});
|
||||
|
||||
initializeAgents([parent, child]);
|
||||
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
||||
|
||||
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
|
||||
expect(
|
||||
selectSubagentsForParent(
|
||||
useSessionStore.getState(),
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: "parent-agent",
|
||||
},
|
||||
new Set(),
|
||||
).map((row) => row.id),
|
||||
).toEqual(["child-agent"]);
|
||||
|
||||
appendAgent({ ...child, parentAgentId: null, labels: {} });
|
||||
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
||||
|
||||
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent", "agent_child-agent"]);
|
||||
expect(
|
||||
selectSubagentsForParent(
|
||||
useSessionStore.getState(),
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: "parent-agent",
|
||||
},
|
||||
new Set(),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ interface FakeArchiveSubagentEnv {
|
||||
deps: ArchiveSubagentDeps;
|
||||
recordedArchives: RecordedArchive[];
|
||||
recordedConfirmInputs: ConfirmDialogInput[];
|
||||
recordedErrors: unknown[];
|
||||
setSubagent(id: string, snapshot: ResolveArchiveSubagentDialogInput | undefined): void;
|
||||
}
|
||||
|
||||
@@ -31,10 +32,12 @@ function createFakeEnv(
|
||||
}
|
||||
const recordedArchives: RecordedArchive[] = [];
|
||||
const recordedConfirmInputs: ConfirmDialogInput[] = [];
|
||||
const recordedErrors: unknown[] = [];
|
||||
|
||||
return {
|
||||
recordedArchives,
|
||||
recordedConfirmInputs,
|
||||
recordedErrors,
|
||||
setSubagent(id, snapshot) {
|
||||
subagents.set(id, snapshot);
|
||||
},
|
||||
@@ -47,6 +50,9 @@ function createFakeEnv(
|
||||
archiveAgent: async (input) => {
|
||||
recordedArchives.push(input);
|
||||
},
|
||||
reportError: (error) => {
|
||||
recordedErrors.push(error);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -176,7 +182,7 @@ describe("requestArchiveSubagent", () => {
|
||||
expect(env.recordedArchives).toEqual([]);
|
||||
});
|
||||
|
||||
it("swallows archive errors so the caller never sees them", async () => {
|
||||
it("reports archive errors after the user confirms", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: true,
|
||||
initialSubagents: [
|
||||
@@ -186,12 +192,14 @@ describe("requestArchiveSubagent", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const error = new Error("daemon offline");
|
||||
env.deps.archiveAgent = async () => {
|
||||
throw new Error("daemon offline");
|
||||
throw error;
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
|
||||
).resolves.toBeUndefined();
|
||||
expect(env.recordedErrors).toEqual([error]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface ArchiveSubagentDeps {
|
||||
getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined;
|
||||
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
|
||||
archiveAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
|
||||
reportError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface RequestArchiveSubagentInput {
|
||||
@@ -62,5 +63,9 @@ export async function requestArchiveSubagent(
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
void deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId }).catch(() => {});
|
||||
try {
|
||||
await deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId });
|
||||
} catch (error) {
|
||||
deps.reportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
requestDetachSubagent,
|
||||
resolveDetachSubagentDialog,
|
||||
type DetachSubagentDeps,
|
||||
type ResolveDetachSubagentDialogInput,
|
||||
} from "./detach-subagent";
|
||||
|
||||
interface RecordedDetach {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface RecordedOpen {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface FakeDetachSubagentEnv {
|
||||
deps: DetachSubagentDeps;
|
||||
recordedDetaches: RecordedDetach[];
|
||||
recordedOpens: RecordedOpen[];
|
||||
recordedConfirmInputs: ConfirmDialogInput[];
|
||||
recordedErrors: unknown[];
|
||||
}
|
||||
|
||||
function createFakeEnv(
|
||||
options: {
|
||||
confirmResult?: boolean;
|
||||
initialSubagents?: Array<{ id: string; snapshot: ResolveDetachSubagentDialogInput }>;
|
||||
} = {},
|
||||
): FakeDetachSubagentEnv {
|
||||
const subagents = new Map<string, ResolveDetachSubagentDialogInput | undefined>();
|
||||
for (const entry of options.initialSubagents ?? []) {
|
||||
subagents.set(entry.id, entry.snapshot);
|
||||
}
|
||||
const recordedDetaches: RecordedDetach[] = [];
|
||||
const recordedOpens: RecordedOpen[] = [];
|
||||
const recordedConfirmInputs: ConfirmDialogInput[] = [];
|
||||
const recordedErrors: unknown[] = [];
|
||||
|
||||
return {
|
||||
recordedDetaches,
|
||||
recordedOpens,
|
||||
recordedConfirmInputs,
|
||||
recordedErrors,
|
||||
deps: {
|
||||
getSubagent: (id) => subagents.get(id),
|
||||
confirm: async (dialog) => {
|
||||
recordedConfirmInputs.push(dialog);
|
||||
return options.confirmResult ?? false;
|
||||
},
|
||||
detachAgent: async (input) => {
|
||||
recordedDetaches.push(input);
|
||||
},
|
||||
openDetachedAgent: (input) => {
|
||||
recordedOpens.push(input);
|
||||
},
|
||||
reportError: (error) => {
|
||||
recordedErrors.push(error);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDetachSubagentDialog", () => {
|
||||
it("uses non-destructive copy for named subagents", () => {
|
||||
expect(resolveDetachSubagentDialog({ title: "Review branch" })).toEqual({
|
||||
title: "Detach subagent?",
|
||||
message: "Review branch will leave this track and continue as a standalone agent.",
|
||||
confirmLabel: "Detach",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to this subagent when the title is not displayable", () => {
|
||||
expect(resolveDetachSubagentDialog({ title: "New Agent" })).toEqual({
|
||||
title: "Detach subagent?",
|
||||
message: "This subagent will leave this track and continue as a standalone agent.",
|
||||
confirmLabel: "Detach",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestDetachSubagent", () => {
|
||||
it("detaches the subagent with the server id when the user confirms", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: true,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
|
||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
||||
|
||||
expect(env.recordedDetaches).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
||||
});
|
||||
|
||||
it("opens the detached subagent after detach succeeds", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: true,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
|
||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
||||
|
||||
expect(env.recordedOpens).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
||||
});
|
||||
|
||||
it("does not detach the subagent when the user cancels", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: false,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
|
||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
||||
|
||||
expect(env.recordedDetaches).toEqual([]);
|
||||
expect(env.recordedOpens).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks for confirmation using the resolved dialog for the subagent", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: false,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
|
||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
||||
|
||||
expect(env.recordedConfirmInputs).toEqual([
|
||||
resolveDetachSubagentDialog({ title: "Review branch" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports detach errors after the user confirms", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: true,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
const error = new Error("daemon offline");
|
||||
env.deps.detachAgent = async () => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
|
||||
).resolves.toBeUndefined();
|
||||
expect(env.recordedErrors).toEqual([error]);
|
||||
expect(env.recordedOpens).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
|
||||
|
||||
export interface ResolveDetachSubagentDialogInput {
|
||||
title: Agent["title"] | null | undefined;
|
||||
}
|
||||
|
||||
function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.toLowerCase() === "new agent") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveDetachSubagentDialog(
|
||||
input: ResolveDetachSubagentDialogInput,
|
||||
): ConfirmDialogInput {
|
||||
const subagentLabel = resolveSubagentLabel(input.title) ?? "This subagent";
|
||||
|
||||
return {
|
||||
title: "Detach subagent?",
|
||||
message: `${subagentLabel} will leave this track and continue as a standalone agent.`,
|
||||
confirmLabel: "Detach",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetachSubagentDeps {
|
||||
getSubagent: (subagentId: string) => ResolveDetachSubagentDialogInput | undefined;
|
||||
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
|
||||
detachAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
|
||||
openDetachedAgent: (input: { serverId: string; agentId: string }) => void;
|
||||
reportError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface RequestDetachSubagentInput {
|
||||
serverId: string;
|
||||
subagentId: string;
|
||||
}
|
||||
|
||||
export async function requestDetachSubagent(
|
||||
input: RequestDetachSubagentInput,
|
||||
deps: DetachSubagentDeps,
|
||||
): Promise<void> {
|
||||
const subagent = deps.getSubagent(input.subagentId);
|
||||
const confirmed = await deps.confirm(
|
||||
resolveDetachSubagentDialog({
|
||||
title: subagent?.title,
|
||||
}),
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deps.detachAgent({ serverId: input.serverId, agentId: input.subagentId });
|
||||
deps.openDetachedAgent({ serverId: input.serverId, agentId: input.subagentId });
|
||||
} catch (error) {
|
||||
deps.reportError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export type { SubagentRow } from "./select";
|
||||
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
|
||||
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
|
||||
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
|
||||
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
||||
export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -18,6 +18,7 @@ import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-pre
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
const ThemedUnlink = withUnistyles(Unlink);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -28,6 +29,7 @@ export interface SubagentsTrackProps {
|
||||
rows: SubagentRow[];
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
|
||||
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
|
||||
@@ -43,6 +45,7 @@ export function SubagentsTrack({
|
||||
rows,
|
||||
onOpenSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackProps): ReactElement | null {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@@ -103,6 +106,7 @@ export function SubagentsTrack({
|
||||
row={row}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
@@ -117,12 +121,14 @@ interface SubagentsTrackRowProps {
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
|
||||
function SubagentsTrackRow({
|
||||
row,
|
||||
onOpenSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -136,10 +142,13 @@ function SubagentsTrackRow({
|
||||
const handleArchivePress = useCallback(() => {
|
||||
onArchiveSubagent(row.id);
|
||||
}, [onArchiveSubagent, row.id]);
|
||||
const handleDetachPress = useCallback(() => {
|
||||
onDetachSubagent?.(row.id);
|
||||
}, [onDetachSubagent, row.id]);
|
||||
const handlePointerEnter = useCallback(() => setHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setHovered(false), []);
|
||||
const archiveAlwaysVisible = isNative || isCompact;
|
||||
const archiveVisible = archiveAlwaysVisible || hovered;
|
||||
const actionsAlwaysVisible = isNative || isCompact;
|
||||
const actionsVisible = actionsAlwaysVisible || hovered;
|
||||
|
||||
return (
|
||||
// Wrapper View handles hover so moving the pointer between the row and
|
||||
@@ -158,11 +167,12 @@ function SubagentsTrackRow({
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
<SubagentArchiveButton
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
visible={archiveVisible}
|
||||
onPress={handleArchivePress}
|
||||
visible={actionsVisible}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -171,49 +181,93 @@ function SubagentsTrackRow({
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentArchiveButton({
|
||||
function SubagentRowActions({
|
||||
rowId,
|
||||
displayLabel,
|
||||
visible,
|
||||
onPress,
|
||||
onDetachPress,
|
||||
onArchivePress,
|
||||
}: {
|
||||
rowId: string;
|
||||
displayLabel: string;
|
||||
visible: boolean;
|
||||
onPress: () => void;
|
||||
onDetachPress?: () => void;
|
||||
onArchivePress: () => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={visible ? styles.archiveSlotVisible : styles.archiveSlotHidden}
|
||||
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
||||
testID={`subagents-track-archive-${rowId}`}
|
||||
onPress={onPress}
|
||||
style={styles.archiveButton}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<ThemedArchive
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("subagents.archiveTooltip")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onDetachPress ? (
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
|
||||
testID={`subagents-track-detach-${rowId}`}
|
||||
tooltipLabel={t("subagents.detachTooltip")}
|
||||
icon="detach"
|
||||
visible={visible}
|
||||
onPress={onDetachPress}
|
||||
/>
|
||||
) : null}
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
||||
testID={`subagents-track-archive-${rowId}`}
|
||||
tooltipLabel={t("subagents.archiveTooltip")}
|
||||
icon="archive"
|
||||
visible={visible}
|
||||
onPress={onArchivePress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type SubagentActionIcon = "archive" | "detach";
|
||||
|
||||
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
|
||||
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
if (icon === "detach") {
|
||||
return <ThemedUnlink size={14} uniProps={uniProps} />;
|
||||
}
|
||||
return <ThemedArchive size={14} uniProps={uniProps} />;
|
||||
}
|
||||
|
||||
function SubagentActionButton({
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
tooltipLabel,
|
||||
icon,
|
||||
visible,
|
||||
onPress,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
tooltipLabel: string;
|
||||
icon: SubagentActionIcon;
|
||||
visible: boolean;
|
||||
onPress: () => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
style={styles.actionButton}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
outer: {
|
||||
width: "100%",
|
||||
@@ -288,13 +342,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
archiveSlotVisible: {
|
||||
actionClusterVisible: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
opacity: 1,
|
||||
},
|
||||
archiveSlotHidden: {
|
||||
actionClusterHidden: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
opacity: 0,
|
||||
},
|
||||
archiveButton: {
|
||||
actionButton: {
|
||||
padding: theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent";
|
||||
|
||||
export { resolveArchiveSubagentDialog, requestArchiveSubagent } from "./archive-subagent";
|
||||
@@ -18,6 +20,7 @@ export interface UseArchiveSubagentInput {
|
||||
export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void {
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const { serverId } = input;
|
||||
const toast = useToast();
|
||||
|
||||
return useCallback(
|
||||
(subagentId: string) => {
|
||||
@@ -28,9 +31,12 @@ export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId:
|
||||
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
|
||||
confirm: confirmDialog,
|
||||
archiveAgent,
|
||||
reportError: (error) => {
|
||||
toast.error(toErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[archiveAgent, serverId],
|
||||
[archiveAgent, serverId, toast],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { requestDetachSubagent, type ResolveDetachSubagentDialogInput } from "./detach-subagent";
|
||||
|
||||
export { resolveDetachSubagentDialog, requestDetachSubagent } from "./detach-subagent";
|
||||
export type {
|
||||
DetachSubagentDeps,
|
||||
RequestDetachSubagentInput,
|
||||
ResolveDetachSubagentDialogInput,
|
||||
} from "./detach-subagent";
|
||||
|
||||
export interface UseDetachSubagentInput {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: string) => void {
|
||||
const { serverId } = input;
|
||||
const toast = useToast();
|
||||
|
||||
return useCallback(
|
||||
(subagentId: string) => {
|
||||
void requestDetachSubagent(
|
||||
{ serverId, subagentId },
|
||||
{
|
||||
getSubagent: (id): ResolveDetachSubagentDialogInput | undefined =>
|
||||
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
|
||||
confirm: confirmDialog,
|
||||
detachAgent: async ({ serverId: targetServerId, agentId }) => {
|
||||
const client = useSessionStore.getState().sessions[targetServerId]?.client;
|
||||
if (!client) {
|
||||
throw new Error(i18n.t("workspaceSetup.errors.hostDisconnected"));
|
||||
}
|
||||
await client.detachAgent(agentId);
|
||||
},
|
||||
openDetachedAgent: ({ serverId: targetServerId, agentId }) => {
|
||||
navigateToAgent({ serverId: targetServerId, agentId });
|
||||
},
|
||||
reportError: (error) => {
|
||||
toast.error(toErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[serverId, toast],
|
||||
);
|
||||
}
|
||||
@@ -183,6 +183,7 @@ describe("host settings section slugs", () => {
|
||||
expect(normalizeHostSectionSlug("agents")).toBe("agents");
|
||||
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
|
||||
expect(normalizeHostSectionSlug("providers")).toBe("providers");
|
||||
expect(normalizeHostSectionSlug("usage")).toBe("usage");
|
||||
expect(normalizeHostSectionSlug("host")).toBe("host");
|
||||
});
|
||||
|
||||
|
||||
@@ -417,6 +417,7 @@ export const HOST_SECTION_SLUGS = [
|
||||
"agents",
|
||||
"workspaces",
|
||||
"providers",
|
||||
"usage",
|
||||
"terminals",
|
||||
"host",
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
|
||||
export function showProviderNoticeToast(
|
||||
toast: ToastApi,
|
||||
notice: AgentProviderNotice | null | undefined,
|
||||
): void {
|
||||
if (!notice) {
|
||||
return;
|
||||
}
|
||||
if (notice.type === "error") {
|
||||
toast.error(notice.message);
|
||||
return;
|
||||
}
|
||||
toast.show(notice.message, {
|
||||
variant: "default",
|
||||
durationMs: notice.type === "warning" ? 3200 : undefined,
|
||||
});
|
||||
}
|
||||
@@ -1454,6 +1454,62 @@ test("sends structured first-agent context attachments with create_paseo_worktre
|
||||
});
|
||||
});
|
||||
|
||||
test("sends project.add.request without creating a workspace", 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 addPromise = client.addProject("/tmp/project", "req-add-project");
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "project.add.request",
|
||||
requestId: "req-add-project",
|
||||
cwd: "/tmp/project",
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "project.add.response",
|
||||
payload: {
|
||||
requestId: "req-add-project",
|
||||
project: {
|
||||
projectId: "/tmp/project",
|
||||
projectDisplayName: "project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/tmp/project",
|
||||
projectKind: "git",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(addPromise).resolves.toEqual({
|
||||
requestId: "req-add-project",
|
||||
project: {
|
||||
projectId: "/tmp/project",
|
||||
projectDisplayName: "project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/tmp/project",
|
||||
projectKind: "git",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("sends first-agent prompt context with workspace.create.request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -2762,6 +2818,48 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("detaches an agent through the namespaced detach 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.detachAgent("child-agent");
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toMatchObject({
|
||||
type: "agent.detach.request",
|
||||
agentId: "child-agent",
|
||||
});
|
||||
expect(typeof request.requestId).toBe("string");
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "agent.detach.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
agentId: "child-agent",
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("sends active-scoped fetch_agents_request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -4028,6 +4126,81 @@ test("dispatches terminals_changed events to typed listeners", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("sends provider.usage.list.request and resolves provider.usage.list.response", 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 usagePromise = client.listProviderUsage({ requestId: "usage-1" });
|
||||
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "provider.usage.list.request",
|
||||
requestId: "usage-1",
|
||||
},
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId: "usage-1",
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [
|
||||
{
|
||||
id: "biweekly",
|
||||
label: "Biweekly",
|
||||
usedPct: 23,
|
||||
remainingPct: 77,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(usagePromise).resolves.toEqual({
|
||||
requestId: "usage-1",
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
windows: [
|
||||
{
|
||||
id: "biweekly",
|
||||
label: "Biweekly",
|
||||
usedPct: 23,
|
||||
remainingPct: 77,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("sends close_items_request and resolves close_items_response", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
PaseoWorktreeListResponse,
|
||||
PaseoWorktreeArchiveResponse,
|
||||
ProjectIconResponse,
|
||||
ProjectAddResponse,
|
||||
OpenProjectResponseMessage,
|
||||
ArchiveWorkspaceResponseMessage,
|
||||
WorkspaceSetupStatusResponseMessage,
|
||||
@@ -63,6 +64,7 @@ import type {
|
||||
GetProvidersSnapshotResponseMessage,
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
ProviderDiagnosticResponseMessage,
|
||||
ProviderUsageListResponseMessage,
|
||||
DaemonGetStatusResponse,
|
||||
DaemonGetPairingOfferResponse,
|
||||
AgentRewindResponseMessage,
|
||||
@@ -85,6 +87,7 @@ import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentProviderNotice,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -345,6 +348,7 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
|
||||
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||
type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||
type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
|
||||
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
|
||||
type ReadProjectConfigPayload = Extract<
|
||||
@@ -658,6 +662,7 @@ export interface RenameTerminalInput {
|
||||
requestId?: string;
|
||||
}
|
||||
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
|
||||
type ProjectAddPayload = ProjectAddResponse["payload"];
|
||||
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"];
|
||||
type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"];
|
||||
|
||||
@@ -1881,6 +1886,18 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async addProject(cwd: string, requestId?: string): Promise<ProjectAddPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "project.add.request",
|
||||
cwd,
|
||||
},
|
||||
responseType: "project.add.response",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
async startWorkspaceScript(
|
||||
workspaceId: string,
|
||||
scriptName: string,
|
||||
@@ -2098,6 +2115,19 @@ export class DaemonClient {
|
||||
return { archivedAt: result.archivedAt };
|
||||
}
|
||||
|
||||
async detachAgent(agentId: string): Promise<void> {
|
||||
const payload = await this.sendNamespacedCorrelatedSessionRequest<"agent.detach.response">({
|
||||
message: {
|
||||
type: "agent.detach.request",
|
||||
agentId,
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
if (!payload.accepted) {
|
||||
throw new Error(payload.error ?? "detachAgent rejected");
|
||||
}
|
||||
}
|
||||
|
||||
async updateAgent(
|
||||
agentId: string,
|
||||
updates: { name?: string; labels?: Record<string, string> },
|
||||
@@ -2431,7 +2461,7 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "set_agent_mode_request",
|
||||
@@ -2457,6 +2487,7 @@ export class DaemonClient {
|
||||
if (!payload.accepted) {
|
||||
throw new Error(payload.error ?? "setAgentMode rejected");
|
||||
}
|
||||
return payload.notice ?? null;
|
||||
}
|
||||
|
||||
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
|
||||
@@ -2516,7 +2547,10 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
|
||||
async setAgentThinkingOption(
|
||||
agentId: string,
|
||||
thinkingOptionId: string | null,
|
||||
): Promise<AgentProviderNotice | null> {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "set_agent_thinking_request",
|
||||
@@ -2542,6 +2576,7 @@ export class DaemonClient {
|
||||
if (!payload.accepted) {
|
||||
throw new Error(payload.error ?? "setAgentThinkingOption rejected");
|
||||
}
|
||||
return payload.notice ?? null;
|
||||
}
|
||||
|
||||
async restartServer(reason?: string, requestId?: string): Promise<RestartRequestedStatusPayload> {
|
||||
@@ -3793,6 +3828,16 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listProviderUsage(options?: { requestId?: string }): Promise<ProviderUsageListPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "provider.usage.list.request",
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
|
||||
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
|
||||
async listCommands(
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface PaseoAgentHandle {
|
||||
refetch(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
|
||||
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
|
||||
archive(): Promise<{ archivedAt: string }>;
|
||||
detach(): Promise<void>;
|
||||
subscribe(handler: (update: PaseoAgentUpdate) => void): () => void;
|
||||
}
|
||||
|
||||
@@ -483,6 +484,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
|
||||
}
|
||||
return result;
|
||||
},
|
||||
detach: async () => {
|
||||
await daemonClient.detachAgent(id);
|
||||
},
|
||||
subscribe: (handler) =>
|
||||
daemonClient.on("agent_update", (message) => {
|
||||
const update = message.payload;
|
||||
|
||||
@@ -4,6 +4,7 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$DesktopDir = (Resolve-Path "$ScriptDir\..").Path
|
||||
$AppDir = (Resolve-Path "$DesktopDir\..\app").Path
|
||||
$RootDir = (Resolve-Path "$DesktopDir\..\..").Path
|
||||
$env:PATH = "$RootDir\node_modules\.bin;$env:PATH"
|
||||
|
||||
# Build the Electron main process
|
||||
npm run build:main
|
||||
@@ -78,18 +79,27 @@ if (-not $env:PASEO_LISTEN) { $env:PASEO_LISTEN = "127.0.0.1:$DevDaemonPort" }
|
||||
# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME
|
||||
# (that could clobber a production config.json with the dev port + wildcard CORS).
|
||||
if ($PaseoHomeManaged) {
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [path, port] = [process.argv[1], process.argv[2]];
|
||||
$env:TMP_CFG_PATH = "$($env:PASEO_HOME)/config.json"
|
||||
$env:TMP_CFG_PORT = $DevDaemonPort
|
||||
$TmpScript = [System.IO.Path]::GetTempFileName() + ".js"
|
||||
$ScriptContent = @"
|
||||
const fs = require('fs');
|
||||
const path = process.env.TMP_CFG_PATH;
|
||||
const port = process.env.TMP_CFG_PORT;
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {}
|
||||
try { cfg = JSON.parse(fs.readFileSync(path, 'utf8')); } catch(e) {}
|
||||
cfg.version = cfg.version || 1;
|
||||
cfg.daemon = cfg.daemon || {};
|
||||
cfg.daemon.listen = `127.0.0.1:${port}`;
|
||||
cfg.daemon.listen = '127.0.0.1:' + port;
|
||||
cfg.daemon.cors = cfg.daemon.cors || {};
|
||||
cfg.daemon.cors.allowedOrigins = ["*"];
|
||||
cfg.daemon.cors.allowedOrigins = ['*'];
|
||||
fs.writeFileSync(path, JSON.stringify(cfg, null, 2));
|
||||
' "$($env:PASEO_HOME)/config.json" $DevDaemonPort
|
||||
"@
|
||||
Set-Content -Path $TmpScript -Value $ScriptContent
|
||||
node $TmpScript
|
||||
Remove-Item $TmpScript -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\TMP_CFG_PATH -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:\TMP_CFG_PORT -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Host " (custom PASEO_HOME - leaving its config.json untouched)"
|
||||
}
|
||||
@@ -107,9 +117,9 @@ Write-Host @"
|
||||
"@
|
||||
|
||||
# Launch Metro + Electron together, kill both on exit
|
||||
& "$RootDir\node_modules\.bin\concurrently" `
|
||||
concurrently `
|
||||
--kill-others `
|
||||
--names "metro,electron" `
|
||||
--prefix-colors "magenta,cyan" `
|
||||
"cd `"$AppDir`" && `$env:PASEO_WEB_PLATFORM = `"electron`"; npx expo start --port $($env:EXPO_PORT)" `
|
||||
"cd `"$AppDir`" && cross-env PASEO_WEB_PLATFORM=electron npx expo start --port $($env:EXPO_PORT)" `
|
||||
"npx wait-on tcp:$($env:EXPO_PORT) && npx electron `"$DesktopDir`""
|
||||
|
||||
@@ -6,6 +6,11 @@ export interface AgentMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type AgentProviderNotice =
|
||||
| { type: "info"; message: string }
|
||||
| { type: "warning"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
/**
|
||||
* Stdio-based MCP server (spawns a subprocess).
|
||||
*/
|
||||
|
||||
@@ -354,4 +354,18 @@ describe("checkout PR schemas", () => {
|
||||
projectRemove: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the project add server_info feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
features: {
|
||||
projectAdd: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({
|
||||
projectAdd: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
FileExplorerRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
parseServerInfoStatusPayload,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
@@ -131,6 +133,179 @@ describe("workspace descriptor message compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider usage list message contract", () => {
|
||||
test("accepts the usage list request as a namespaced correlated RPC", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "provider.usage.list.request",
|
||||
requestId: "usage-1",
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "provider.usage.list.request",
|
||||
requestId: "usage-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts new providers and new usage windows as normalized data", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId: "usage-2",
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "glm",
|
||||
displayName: "GLM coding plan",
|
||||
status: "available",
|
||||
planLabel: "GLM coding plan",
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
windows: [
|
||||
{
|
||||
id: "biweekly",
|
||||
label: "Biweekly",
|
||||
usedPct: 23,
|
||||
remainingPct: 77,
|
||||
resetsAt: "2026-07-03T00:00:00.000Z",
|
||||
tone: "ok",
|
||||
},
|
||||
],
|
||||
balances: [
|
||||
{
|
||||
id: "credits",
|
||||
label: "Credits",
|
||||
remaining: 120,
|
||||
unit: "credits",
|
||||
},
|
||||
],
|
||||
details: [{ id: "region", label: "Region", value: "US" }],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("provider.usage.list.response");
|
||||
if (parsed.type !== "provider.usage.list.response") {
|
||||
throw new Error("Expected provider.usage.list.response");
|
||||
}
|
||||
expect(parsed.payload.providers[0]?.providerId).toBe("glm");
|
||||
expect(parsed.payload.providers[0]?.windows[0]?.label).toBe("Biweekly");
|
||||
});
|
||||
|
||||
test("keeps protocol numbers strict after API boundary normalization", () => {
|
||||
const parsed = SessionOutboundMessageSchema.safeParse({
|
||||
type: "provider.usage.list.response",
|
||||
payload: {
|
||||
requestId: "usage-3",
|
||||
fetchedAt: "2026-06-19T00:00:00.000Z",
|
||||
providers: [
|
||||
{
|
||||
providerId: "claude",
|
||||
displayName: "Claude",
|
||||
status: "available",
|
||||
planLabel: "Max 20x",
|
||||
windows: [
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
usedPct: "7",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent detach RPC", () => {
|
||||
test("parses the namespaced detach request", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "agent.detach.request",
|
||||
agentId: "child-agent",
|
||||
requestId: "req-detach",
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
type: "agent.detach.request",
|
||||
agentId: "child-agent",
|
||||
requestId: "req-detach",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses the namespaced detach response", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "agent.detach.response",
|
||||
payload: {
|
||||
requestId: "req-detach",
|
||||
agentId: "child-agent",
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("agent.detach.response");
|
||||
});
|
||||
|
||||
test("parses the agentDetach server feature gate", () => {
|
||||
const parsed = parseServerInfoStatusPayload({
|
||||
status: "server_info",
|
||||
serverId: "srv-test",
|
||||
features: {
|
||||
agentDetach: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!parsed) {
|
||||
throw new Error("Expected server info payload to parse");
|
||||
}
|
||||
expect(parsed.features?.agentDetach).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent setting action responses", () => {
|
||||
test("parses optional provider notices on mode and thinking responses", () => {
|
||||
const mode = SessionOutboundMessageSchema.parse({
|
||||
type: "set_agent_mode_response",
|
||||
payload: {
|
||||
requestId: "req-mode",
|
||||
agentId: "agent-1",
|
||||
accepted: true,
|
||||
error: null,
|
||||
notice: {
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
},
|
||||
},
|
||||
});
|
||||
const thinking = SessionOutboundMessageSchema.parse({
|
||||
type: "set_agent_thinking_response",
|
||||
payload: {
|
||||
requestId: "req-thinking",
|
||||
agentId: "agent-1",
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mode.type).toBe("set_agent_mode_response");
|
||||
if (mode.type !== "set_agent_mode_response") {
|
||||
throw new Error("Expected set_agent_mode_response");
|
||||
}
|
||||
expect(mode.payload.notice).toEqual({
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
});
|
||||
expect(thinking.type).toBe("set_agent_thinking_response");
|
||||
if (thinking.type !== "set_agent_thinking_response") {
|
||||
throw new Error("Expected set_agent_thinking_response");
|
||||
}
|
||||
expect(thinking.payload.notice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("file explorer request compatibility", () => {
|
||||
test("acceptBinary is optional for old clients and accepted for new clients", () => {
|
||||
expect(
|
||||
|
||||
@@ -172,6 +172,7 @@ import type {
|
||||
ProviderStatus,
|
||||
AgentRuntimeInfo,
|
||||
AgentTimelineItem,
|
||||
AgentProviderNotice,
|
||||
ToolCallDetail,
|
||||
ToolCallTimelineItem,
|
||||
AgentUsage,
|
||||
@@ -202,6 +203,12 @@ const AgentSelectOptionSchema = z.object({
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const AgentProviderNoticeSchema: z.ZodType<AgentProviderNotice> = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("info"), message: z.string() }),
|
||||
z.object({ type: z.literal("warning"), message: z.string() }),
|
||||
z.object({ type: z.literal("error"), message: z.string() }),
|
||||
]);
|
||||
|
||||
export const AgentFeatureToggleSchema = z.object({
|
||||
type: z.literal("toggle"),
|
||||
id: z.string(),
|
||||
@@ -1194,6 +1201,11 @@ export const ProviderDiagnosticRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderUsageListRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.usage.list.request"),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ResumeAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("resume_agent_request"),
|
||||
handle: AgentPersistenceHandleSchema,
|
||||
@@ -1264,6 +1276,7 @@ const AgentActionResponsePayloadSchema = z.object({
|
||||
agentId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
notice: AgentProviderNoticeSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export const SetAgentModeResponseMessageSchema = z.object({
|
||||
@@ -1308,6 +1321,17 @@ export const SetAgentFeatureResponseMessageSchema = z.object({
|
||||
payload: AgentActionResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const AgentDetachRequestMessageSchema = z.object({
|
||||
type: z.literal("agent.detach.request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AgentDetachResponseMessageSchema = z.object({
|
||||
type: z.literal("agent.detach.response"),
|
||||
payload: AgentActionResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const AgentRewindModeSchema = z.enum(["conversation", "files", "both"]);
|
||||
|
||||
export const AgentRewindRequestMessageSchema = z.object({
|
||||
@@ -1690,6 +1714,12 @@ export const OpenProjectRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectAddRequestSchema = z.object({
|
||||
type: z.literal("project.add.request"),
|
||||
cwd: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ArchiveWorkspaceRequestSchema = z.object({
|
||||
type: z.literal("archive_workspace_request"),
|
||||
workspaceId: z.string(),
|
||||
@@ -2010,6 +2040,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
GetProvidersSnapshotRequestMessageSchema,
|
||||
RefreshProvidersSnapshotRequestMessageSchema,
|
||||
ProviderDiagnosticRequestMessageSchema,
|
||||
ProviderUsageListRequestMessageSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
ImportAgentRequestMessageSchema,
|
||||
RefreshAgentRequestMessageSchema,
|
||||
@@ -2021,6 +2052,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModelRequestMessageSchema,
|
||||
SetAgentThinkingRequestMessageSchema,
|
||||
SetAgentFeatureRequestMessageSchema,
|
||||
AgentDetachRequestMessageSchema,
|
||||
AgentRewindRequestMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
CheckoutStatusRequestSchema,
|
||||
@@ -2054,6 +2086,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
LegacyListAvailableEditorsRequestSchema,
|
||||
LegacyOpenInEditorRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
ProjectAddRequestSchema,
|
||||
ArchiveWorkspaceRequestSchema,
|
||||
WorkspaceCreateRequestSchema,
|
||||
WorkspaceClearAttentionRequestSchema,
|
||||
@@ -2278,8 +2311,14 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceMultiplicity: z.boolean().optional(),
|
||||
// COMPAT(projectRemove): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||
projectRemove: z.boolean().optional(),
|
||||
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||
projectAdd: z.boolean().optional(),
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
worktreeRestore: z.boolean().optional(),
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: z.boolean().optional(),
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
agentDetach: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -2669,8 +2708,9 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
|
||||
});
|
||||
|
||||
// COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97.
|
||||
// A project parent that has zero active workspaces. The sidebar renders it as an
|
||||
// empty project row so projects persist after their last workspace is archived.
|
||||
// A project parent that has zero active workspaces. The sidebar renders the
|
||||
// project row with a new-workspace child so projects persist after their last
|
||||
// workspace is archived.
|
||||
export const WorkspaceProjectDescriptorPayloadSchema = z.object({
|
||||
projectId: z.string(),
|
||||
projectDisplayName: z.string(),
|
||||
@@ -2709,10 +2749,10 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
// COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97.
|
||||
// When archiving this workspace leaves its project with no active
|
||||
// workspaces, the daemon includes the now-empty project parent so the
|
||||
// sidebar keeps rendering it without waiting for a full re-hydration. Old
|
||||
// daemons omit it; old clients ignore it and surface the empty project on
|
||||
// their next workspace fetch instead.
|
||||
// workspaces, the daemon includes the project parent so the sidebar keeps
|
||||
// rendering it without waiting for a full re-hydration. Old daemons omit
|
||||
// it; old clients ignore it and surface the project on their next
|
||||
// workspace fetch instead.
|
||||
emptyProject: WorkspaceProjectDescriptorPayloadSchema.optional(),
|
||||
// Project removal is represented on the existing workspace update channel
|
||||
// so old clients can still parse the message and ignore the extra field.
|
||||
@@ -2765,6 +2805,16 @@ export const OpenProjectResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProjectAddResponseSchema = z.object({
|
||||
type: z.literal("project.add.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
project: WorkspaceProjectDescriptorPayloadSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
errorCode: z.enum(["directory_not_found"]).nullish().catch(null),
|
||||
}),
|
||||
});
|
||||
|
||||
export const StartWorkspaceScriptResponseMessageSchema = z.object({
|
||||
type: z.literal("start_workspace_script_response"),
|
||||
payload: z.object({
|
||||
@@ -3808,6 +3858,61 @@ export const ProviderDiagnosticResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProviderUsageToneSchema = z.enum(["default", "ok", "warning", "danger"]);
|
||||
export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "error"]);
|
||||
|
||||
export const ProviderUsageWindowSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
usedPct: z.number().nullable().optional(),
|
||||
remainingPct: z.number().nullable().optional(),
|
||||
resetsAt: z.string().nullable().optional(),
|
||||
runsOutAt: z.string().nullable().optional(),
|
||||
shortfallPct: z.number().nullable().optional(),
|
||||
tone: ProviderUsageToneSchema.optional(),
|
||||
});
|
||||
|
||||
export const ProviderUsageBalanceSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
used: z.number().nullable().optional(),
|
||||
remaining: z.number().nullable().optional(),
|
||||
limit: z.number().nullable().optional(),
|
||||
unit: z.enum(["usd", "credits", "requests", "tokens"]),
|
||||
resetsAt: z.string().nullable().optional(),
|
||||
tone: ProviderUsageToneSchema.optional(),
|
||||
});
|
||||
|
||||
export const ProviderUsageDetailSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
tone: ProviderUsageToneSchema.optional(),
|
||||
});
|
||||
|
||||
export const ProviderUsageSchema = z.object({
|
||||
providerId: z.string(),
|
||||
displayName: z.string(),
|
||||
status: ProviderUsageStatusSchema,
|
||||
planLabel: z.string().nullable(),
|
||||
sourceLabel: z.string().nullable().optional(),
|
||||
fetchedAt: z.string().nullable().optional(),
|
||||
nextRefreshAt: z.string().nullable().optional(),
|
||||
windows: z.array(ProviderUsageWindowSchema),
|
||||
balances: z.array(ProviderUsageBalanceSchema).optional(),
|
||||
details: z.array(ProviderUsageDetailSchema).optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const ProviderUsageListResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.usage.list.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
fetchedAt: z.string(),
|
||||
providers: z.array(ProviderUsageSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
const AgentSlashCommandSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
@@ -3996,6 +4101,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchAgentHistoryResponseMessageSchema,
|
||||
FetchRecentProviderSessionsResponseMessageSchema,
|
||||
FetchWorkspacesResponseMessageSchema,
|
||||
ProjectAddResponseSchema,
|
||||
OpenProjectResponseMessageSchema,
|
||||
StartWorkspaceScriptResponseMessageSchema,
|
||||
LegacyListAvailableEditorsResponseMessageSchema,
|
||||
@@ -4019,6 +4125,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModelResponseMessageSchema,
|
||||
SetAgentThinkingResponseMessageSchema,
|
||||
SetAgentFeatureResponseMessageSchema,
|
||||
AgentDetachResponseMessageSchema,
|
||||
AgentRewindResponseMessageSchema,
|
||||
UpdateAgentResponseMessageSchema,
|
||||
ProjectRenameResponseSchema,
|
||||
@@ -4070,6 +4177,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
RefreshProvidersSnapshotResponseMessageSchema,
|
||||
ProviderDiagnosticResponseMessageSchema,
|
||||
ProviderUsageListResponseMessageSchema,
|
||||
ListCommandsResponseSchema,
|
||||
ListTerminalsResponseSchema,
|
||||
TerminalsChangedSchema,
|
||||
@@ -4143,6 +4251,7 @@ export type FetchRecentProviderSessionsResponseMessage = z.infer<
|
||||
typeof FetchRecentProviderSessionsResponseMessageSchema
|
||||
>;
|
||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||
export type ProjectAddResponse = z.infer<typeof ProjectAddResponseSchema>;
|
||||
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
|
||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
||||
export type StartWorkspaceScriptResponseMessage = z.infer<
|
||||
@@ -4166,6 +4275,7 @@ export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMes
|
||||
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
|
||||
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
|
||||
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
|
||||
export type AgentDetachResponseMessage = z.infer<typeof AgentDetachResponseMessageSchema>;
|
||||
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
|
||||
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
||||
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
|
||||
@@ -4204,6 +4314,15 @@ export type RefreshProvidersSnapshotResponseMessage = z.infer<
|
||||
export type ProviderDiagnosticResponseMessage = z.infer<
|
||||
typeof ProviderDiagnosticResponseMessageSchema
|
||||
>;
|
||||
export type ProviderUsageTone = z.infer<typeof ProviderUsageToneSchema>;
|
||||
export type ProviderUsageStatus = z.infer<typeof ProviderUsageStatusSchema>;
|
||||
export type ProviderUsage = z.infer<typeof ProviderUsageSchema>;
|
||||
export type ProviderUsageWindow = z.infer<typeof ProviderUsageWindowSchema>;
|
||||
export type ProviderUsageBalance = z.infer<typeof ProviderUsageBalanceSchema>;
|
||||
export type ProviderUsageDetail = z.infer<typeof ProviderUsageDetailSchema>;
|
||||
export type ProviderUsageListResponseMessage = z.infer<
|
||||
typeof ProviderUsageListResponseMessageSchema
|
||||
>;
|
||||
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
|
||||
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
|
||||
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
|
||||
@@ -4299,6 +4418,7 @@ export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessa
|
||||
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
||||
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
||||
export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureRequestMessageSchema>;
|
||||
export type AgentDetachRequestMessage = z.infer<typeof AgentDetachRequestMessageSchema>;
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||
@@ -4375,6 +4495,7 @@ export type LegacyListAvailableEditorsRequest = z.infer<
|
||||
>;
|
||||
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
|
||||
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
||||
export type ProjectAddRequest = z.infer<typeof ProjectAddRequestSchema>;
|
||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||
|
||||
@@ -27,6 +27,50 @@ describe("workspace message schemas", () => {
|
||||
expect(parsed.type).toBe("fetch_workspaces_request");
|
||||
});
|
||||
|
||||
test("parses project.add request and response", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "project.add.request",
|
||||
requestId: "req-add-project",
|
||||
cwd: "/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "project.add.request",
|
||||
requestId: "req-add-project",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "project.add.response",
|
||||
payload: {
|
||||
requestId: "req-add-project",
|
||||
project: {
|
||||
projectId: "/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "project.add.response",
|
||||
payload: {
|
||||
requestId: "req-add-project",
|
||||
project: {
|
||||
projectId: "/repo",
|
||||
projectDisplayName: "repo",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("parses active-scoped fetch_agents_request as an optional extension", () => {
|
||||
const legacy = SessionInboundMessageSchema.parse({
|
||||
type: "fetch_agents_request",
|
||||
|
||||
@@ -1847,18 +1847,23 @@ test("updateAgentMetadata bumps updatedAt for stored agents", async () => {
|
||||
});
|
||||
await manager.closeAgent(snapshot.id);
|
||||
|
||||
const before = await storage.get(snapshot.id);
|
||||
expect(before).not.toBeNull();
|
||||
const closed = await storage.get(snapshot.id);
|
||||
expect(closed).not.toBeNull();
|
||||
const before = { ...closed!, labels: { surface: "mobile" } };
|
||||
await storage.upsert(before);
|
||||
expect(manager.getAgent(snapshot.id)).toBeNull();
|
||||
|
||||
const upsertSpy = vi.spyOn(storage, "upsert");
|
||||
|
||||
await manager.updateAgentMetadata(snapshot.id, {
|
||||
title: "Stored title",
|
||||
labels: { role: "worker" },
|
||||
});
|
||||
|
||||
expect(upsertSpy).toHaveBeenCalledTimes(1);
|
||||
const after = await storage.get(snapshot.id);
|
||||
expect(after?.title).toBe("Stored title");
|
||||
expect(after?.labels).toEqual({ role: "worker" });
|
||||
expect(after?.labels).toEqual({ surface: "mobile", role: "worker" });
|
||||
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
|
||||
});
|
||||
|
||||
@@ -2002,6 +2007,134 @@ test("setLabels merges and persists labels", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("detachAgent removes only the parent label from a live agent and emits state", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const parent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Parent",
|
||||
});
|
||||
const child = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Child",
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
team: "infra",
|
||||
},
|
||||
},
|
||||
);
|
||||
const emittedLabels: Array<Record<string, string>> = [];
|
||||
const unsubscribe = manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === child.id) {
|
||||
emittedLabels.push(event.agent.labels);
|
||||
}
|
||||
},
|
||||
{ agentId: child.id, replayState: false },
|
||||
);
|
||||
|
||||
const result = await manager.detachAgent(child.id);
|
||||
await manager.flush();
|
||||
unsubscribe();
|
||||
|
||||
expect(result.previousParentAgentId).toBe(parent.id);
|
||||
expect(result.live).toBe(true);
|
||||
expect(result.record.labels).toEqual({ team: "infra" });
|
||||
expect(manager.getAgent(child.id)?.labels).toEqual({ team: "infra" });
|
||||
expect((await storage.get(child.id))?.labels).toEqual({ team: "infra" });
|
||||
expect(emittedLabels).toContainEqual({ team: "infra" });
|
||||
});
|
||||
|
||||
test("detachAgent removes the parent label from a stored-only agent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const parent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Parent",
|
||||
});
|
||||
const child = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Stored child",
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
role: "reviewer",
|
||||
},
|
||||
},
|
||||
);
|
||||
await manager.closeAgent(child.id);
|
||||
|
||||
const result = await manager.detachAgent(child.id);
|
||||
|
||||
expect(result.previousParentAgentId).toBe(parent.id);
|
||||
expect(result.live).toBe(false);
|
||||
expect(result.record.labels).toEqual({ role: "reviewer" });
|
||||
expect((await storage.get(child.id))?.labels).toEqual({ role: "reviewer" });
|
||||
});
|
||||
|
||||
test("archiveAgent does not cascade to a detached former child", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-cascade-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const parent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Parent",
|
||||
});
|
||||
const child = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Child",
|
||||
},
|
||||
undefined,
|
||||
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id } },
|
||||
);
|
||||
|
||||
await manager.detachAgent(child.id);
|
||||
await manager.archiveAgent(parent.id);
|
||||
|
||||
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
|
||||
expect((await storage.get(child.id))?.archivedAt).toBeFalsy();
|
||||
});
|
||||
|
||||
test("runAgent persists finished attention and idle status without an external snapshot subscriber", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} from "@getpaseo/protocol/agent-lifecycle";
|
||||
import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import {
|
||||
getParentAgentIdFromLabels,
|
||||
isDelegatedAgent,
|
||||
PARENT_AGENT_ID_LABEL,
|
||||
} from "@getpaseo/protocol/agent-labels";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionResult,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentProviderNotice,
|
||||
type AgentPromptInput,
|
||||
type AgentProvider,
|
||||
type AgentRunOptions,
|
||||
@@ -345,6 +350,17 @@ type ActiveManagedAgent =
|
||||
| ManagedAgentError;
|
||||
|
||||
type LiveManagedAgent = ActiveManagedAgent;
|
||||
type AgentLabelPatch = Record<string, string | null>;
|
||||
|
||||
interface WriteLabelsResult {
|
||||
record: StoredAgentRecord | null;
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
interface AgentMetadataPatch {
|
||||
title?: string;
|
||||
labels?: AgentLabelPatch;
|
||||
}
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
|
||||
@@ -403,6 +419,21 @@ function validateAgentId(agentId: string, source: string): string {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function applyLabelPatch(
|
||||
labels: Record<string, string>,
|
||||
patch: AgentLabelPatch,
|
||||
): Record<string, string> {
|
||||
const nextLabels = { ...labels };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete nextLabels[key];
|
||||
} else {
|
||||
nextLabels[key] = value;
|
||||
}
|
||||
}
|
||||
return nextLabels;
|
||||
}
|
||||
|
||||
function buildExplicitTimelineSeedForRegister(
|
||||
now: Date,
|
||||
options:
|
||||
@@ -1151,9 +1182,8 @@ export class AgentManager {
|
||||
|
||||
// Children created via the MCP `create_agent` tool carry the parent-agent-id
|
||||
// label pointing back at the caller. Archiving the parent cascades to those
|
||||
// children so subagent fleets don't outlive their orchestrator. Handoff agents
|
||||
// launched the same way are caught by this cascade — see docs/agent-lifecycle.md
|
||||
// for the accepted limitation.
|
||||
// children so subagent fleets don't outlive their orchestrator. Detached
|
||||
// handoff agents omit this label, so they stand outside the cascade.
|
||||
private async cascadeArchiveChildren(parentAgentId: string): Promise<void> {
|
||||
const registry = this.registry;
|
||||
if (!registry) {
|
||||
@@ -1247,9 +1277,9 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
await agent.session.setMode(modeId);
|
||||
const notice = (await agent.session.setMode(modeId)) ?? null;
|
||||
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
|
||||
agent.config.modeId = currentMode ?? undefined;
|
||||
agent.currentModeId = currentMode;
|
||||
@@ -1259,6 +1289,7 @@ export class AgentManager {
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
return notice;
|
||||
}
|
||||
|
||||
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
|
||||
@@ -1278,15 +1309,19 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
|
||||
async setAgentThinkingOption(
|
||||
agentId: string,
|
||||
thinkingOptionId: string | null,
|
||||
): Promise<AgentProviderNotice | null> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const normalizedThinkingOptionId =
|
||||
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
||||
? thinkingOptionId
|
||||
: null;
|
||||
|
||||
let notice: AgentProviderNotice | null = null;
|
||||
if (agent.session.setThinkingOption) {
|
||||
await agent.session.setThinkingOption(normalizedThinkingOptionId);
|
||||
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
|
||||
}
|
||||
|
||||
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
||||
@@ -1298,6 +1333,7 @@ export class AgentManager {
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
return notice;
|
||||
}
|
||||
|
||||
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||
@@ -1333,10 +1369,83 @@ export class AgentManager {
|
||||
|
||||
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.labels = { ...agent.labels, ...labels };
|
||||
this.touchUpdatedAt(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
await this.writeLabels(agent.id, labels);
|
||||
}
|
||||
|
||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
if (liveAgent) {
|
||||
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
|
||||
this.touchUpdatedAt(liveAgent);
|
||||
await this.persistSnapshot(liveAgent);
|
||||
this.emitState(liveAgent, { persist: false });
|
||||
const record = this.registry ? await this.registry.get(agentId) : null;
|
||||
return { record, live: true };
|
||||
}
|
||||
|
||||
const nextRecord = await this.writeStoredMetadata(agentId, { labels: patch });
|
||||
return { record: nextRecord, live: false };
|
||||
}
|
||||
|
||||
private async writeStoredMetadata(
|
||||
agentId: string,
|
||||
patch: AgentMetadataPatch,
|
||||
): Promise<StoredAgentRecord> {
|
||||
const registry = this.requireRegistry();
|
||||
const record = await registry.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const nextRecord = {
|
||||
...record,
|
||||
...(patch.title ? { title: patch.title } : {}),
|
||||
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
|
||||
updatedAt: this.nextStoredUpdatedAt(record),
|
||||
};
|
||||
await registry.upsert(nextRecord);
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
async detachAgent(agentId: string): Promise<{
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}> {
|
||||
const registry = this.requireRegistry();
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
if (liveAgent) {
|
||||
const previousParentAgentId = getParentAgentIdFromLabels(liveAgent.labels);
|
||||
if (!previousParentAgentId) {
|
||||
await this.persistSnapshot(liveAgent);
|
||||
const record = await registry.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||
}
|
||||
return { record, live: true, previousParentAgentId: null };
|
||||
}
|
||||
|
||||
const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||
}
|
||||
return { record, live: true, previousParentAgentId };
|
||||
}
|
||||
|
||||
const record = await registry.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
const previousParentAgentId = getParentAgentIdFromLabels(record.labels);
|
||||
if (!previousParentAgentId) {
|
||||
return { record, live: false, previousParentAgentId: null };
|
||||
}
|
||||
|
||||
const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||
if (!result.record) {
|
||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||
}
|
||||
return { record: result.record, live: false, previousParentAgentId };
|
||||
}
|
||||
|
||||
notifyAgentState(agentId: string): void {
|
||||
@@ -1433,23 +1542,12 @@ export class AgentManager {
|
||||
await this.setTitle(agentId, updates.title);
|
||||
}
|
||||
if (updates.labels) {
|
||||
await this.setLabels(agentId, updates.labels);
|
||||
await this.writeLabels(agentId, updates.labels);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = this.requireRegistry();
|
||||
const existing = await registry.get(agentId);
|
||||
if (!existing) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
await registry.upsert({
|
||||
...existing,
|
||||
...(updates.title ? { title: updates.title } : {}),
|
||||
...(updates.labels ? { labels: { ...existing.labels, ...updates.labels } } : {}),
|
||||
updatedAt: this.nextStoredUpdatedAt(existing),
|
||||
});
|
||||
await this.writeStoredMetadata(agentId, updates);
|
||||
}
|
||||
|
||||
async runAgent(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Options as ClaudeAgentOptions } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type { AgentProviderNotice };
|
||||
|
||||
export type AgentProvider = string;
|
||||
|
||||
export interface AgentMetadata {
|
||||
@@ -604,7 +607,7 @@ export interface AgentSession {
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
getAvailableModes(): Promise<AgentMode[]>;
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
setMode(modeId: string): Promise<void | AgentProviderNotice>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(
|
||||
requestId: string,
|
||||
@@ -615,7 +618,7 @@ export interface AgentSession {
|
||||
close(): Promise<void>;
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
revertConversation?(input: { messageId: string }): Promise<void>;
|
||||
revertFiles?(input: { messageId: string }): Promise<void>;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getParentAgentIdFromLabels, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
import {
|
||||
archiveAgentCommand,
|
||||
cancelAgentRunCommand,
|
||||
detachAgentCommand,
|
||||
setAgentModeCommand,
|
||||
updateAgentCommand,
|
||||
type LifecycleAgentSnapshot,
|
||||
@@ -39,6 +41,7 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
|
||||
readonly labelUpdates: Array<{ agentId: string; labels: Record<string, string> }> = [];
|
||||
readonly notifiedAgentIds: string[] = [];
|
||||
readonly modeUpdates: Array<{ agentId: string; modeId: string }> = [];
|
||||
readonly detachedAgentIds: string[] = [];
|
||||
inFlightAgentIds = new Set<string>();
|
||||
|
||||
constructor(private readonly storage: FakeLifecycleAgentStorage) {}
|
||||
@@ -94,12 +97,46 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
|
||||
this.labelUpdates.push({ agentId, labels });
|
||||
}
|
||||
|
||||
async detachAgent(agentId: string): Promise<{
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}> {
|
||||
this.detachedAgentIds.push(agentId);
|
||||
const existing = this.storage.records.get(agentId);
|
||||
if (!existing) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
const previousParentAgentId = getParentAgentIdFromLabels(existing.labels);
|
||||
if (!previousParentAgentId) {
|
||||
return {
|
||||
record: existing,
|
||||
live: this.liveAgents.has(agentId),
|
||||
previousParentAgentId: null,
|
||||
};
|
||||
}
|
||||
const labels = { ...existing.labels };
|
||||
delete labels[PARENT_AGENT_ID_LABEL];
|
||||
const record = {
|
||||
...existing,
|
||||
labels,
|
||||
updatedAt: "2026-05-10T10:30:00.000Z",
|
||||
};
|
||||
this.storage.records.set(agentId, record);
|
||||
return {
|
||||
record,
|
||||
live: this.liveAgents.has(agentId),
|
||||
previousParentAgentId,
|
||||
};
|
||||
}
|
||||
|
||||
notifyAgentState(agentId: string): void {
|
||||
this.notifiedAgentIds.push(agentId);
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
async setAgentMode(agentId: string, modeId: string) {
|
||||
this.modeUpdates.push({ agentId, modeId });
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateAgentMetadata(
|
||||
@@ -206,13 +243,51 @@ describe("agent lifecycle commands", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("detaches an agent by clearing only the parent relationship", async () => {
|
||||
const storage = new FakeLifecycleAgentStorage();
|
||||
storage.records.set("agent-1", {
|
||||
...storedAgent("agent-1"),
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: "parent-agent",
|
||||
team: "infra",
|
||||
},
|
||||
});
|
||||
const manager = new FakeLifecycleAgentManager(storage);
|
||||
|
||||
await expect(detachAgentCommand({ agentManager: manager }, "agent-1")).resolves.toEqual({
|
||||
agentId: "agent-1",
|
||||
live: false,
|
||||
previousParentAgentId: "parent-agent",
|
||||
record: {
|
||||
...storedAgent("agent-1"),
|
||||
labels: { team: "infra" },
|
||||
updatedAt: "2026-05-10T10:30:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(manager.detachedAgentIds).toEqual(["agent-1"]);
|
||||
});
|
||||
|
||||
test("detach is accepted when the agent is already detached", async () => {
|
||||
const storage = new FakeLifecycleAgentStorage();
|
||||
storage.records.set("agent-1", storedAgent("agent-1"));
|
||||
const manager = new FakeLifecycleAgentManager(storage);
|
||||
|
||||
await expect(detachAgentCommand({ agentManager: manager }, "agent-1")).resolves.toEqual({
|
||||
agentId: "agent-1",
|
||||
live: false,
|
||||
previousParentAgentId: null,
|
||||
record: storedAgent("agent-1"),
|
||||
});
|
||||
});
|
||||
|
||||
test("sets an agent mode and returns the accepted mode", async () => {
|
||||
const storage = new FakeLifecycleAgentStorage();
|
||||
const manager = new FakeLifecycleAgentManager(storage);
|
||||
|
||||
await expect(
|
||||
setAgentModeCommand({ agentManager: manager }, { agentId: "agent-1", modeId: "plan" }),
|
||||
).resolves.toEqual({ modeId: "plan" });
|
||||
).resolves.toEqual({ modeId: "plan", notice: null });
|
||||
|
||||
expect(manager.modeUpdates).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { AgentProviderNotice } from "./agent-sdk-types.js";
|
||||
|
||||
export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle">;
|
||||
|
||||
@@ -14,8 +15,13 @@ export interface LifecycleAgentManager {
|
||||
archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord>;
|
||||
closeAgent(agentId: string): Promise<void>;
|
||||
setLabels(agentId: string, labels: Record<string, string>): Promise<void>;
|
||||
detachAgent(agentId: string): Promise<{
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}>;
|
||||
notifyAgentState(agentId: string): void;
|
||||
setAgentMode(agentId: string, modeId: string): Promise<void>;
|
||||
setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
|
||||
updateAgentMetadata(
|
||||
agentId: string,
|
||||
updates: {
|
||||
@@ -161,15 +167,33 @@ export async function updateAgentCommand(
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetachAgentResult {
|
||||
agentId: string;
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}
|
||||
|
||||
export async function detachAgentCommand(
|
||||
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
|
||||
agentId: string,
|
||||
): Promise<DetachAgentResult> {
|
||||
const result = await dependencies.agentManager.detachAgent(agentId);
|
||||
return {
|
||||
agentId,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setAgentModeCommand(
|
||||
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
|
||||
input: {
|
||||
agentId: string;
|
||||
modeId: string;
|
||||
},
|
||||
): Promise<{ modeId: string }> {
|
||||
await dependencies.agentManager.setAgentMode(input.agentId, input.modeId);
|
||||
return { modeId: input.modeId };
|
||||
): Promise<{ modeId: string; notice: AgentProviderNotice | null }> {
|
||||
const notice = await dependencies.agentManager.setAgentMode(input.agentId, input.modeId);
|
||||
return { modeId: input.modeId, notice };
|
||||
}
|
||||
|
||||
async function archiveStoredAgent(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AgentProviderNotice } from "./agent-sdk-types.js";
|
||||
|
||||
export const SETTING_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = {
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
ResolveAgentCreateConfigInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
import { ProviderSnapshotManager, resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
@@ -873,4 +873,13 @@ describe("ProviderSnapshotManager cwd routing", () => {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveSnapshotCwd normalizes pure drive letters to append backslash on Windows", () => {
|
||||
const resolved = resolveSnapshotCwd("C:");
|
||||
if (process.platform === "win32") {
|
||||
expect(resolved).toBe("C:\\");
|
||||
} else {
|
||||
expect(resolved).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -766,9 +766,16 @@ export function resolveSnapshotCwd(cwd?: string | null): string {
|
||||
if (!trimmed) {
|
||||
return homedir();
|
||||
}
|
||||
const expanded =
|
||||
let expanded =
|
||||
trimmed === "~" || trimmed.startsWith("~/") ? `${homedir()}${trimmed.slice(1)}` : trimmed;
|
||||
return resolve(expanded);
|
||||
if (process.platform === "win32" && /^[A-Za-z]:$/.test(expanded)) {
|
||||
expanded = `${expanded}\\`;
|
||||
}
|
||||
let resolved = resolve(expanded);
|
||||
if (process.platform === "win32" && /^[A-Za-z]:$/.test(resolved)) {
|
||||
resolved = `${resolved}\\`;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function entriesToArray(
|
||||
|
||||
@@ -947,7 +947,7 @@ test("captures Claude stderr in the turn failure diagnostic when stderr arrives
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves bypass capability across query restarts triggered by thinking changes", async () => {
|
||||
test("omits dangerous permission-skip allowance from default-mode launches", async () => {
|
||||
const capturedOptions: Array<{
|
||||
permissionMode?: string;
|
||||
allowDangerouslySkipPermissions?: boolean;
|
||||
@@ -964,11 +964,7 @@ test("preserves bypass capability across query restarts triggered by thinking ch
|
||||
effort?: string;
|
||||
};
|
||||
}) => {
|
||||
capturedOptions.push({
|
||||
permissionMode: options.permissionMode,
|
||||
allowDangerouslySkipPermissions: options.allowDangerouslySkipPermissions,
|
||||
effort: options.effort,
|
||||
});
|
||||
capturedOptions.push(options);
|
||||
|
||||
return createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||
},
|
||||
@@ -977,21 +973,13 @@ test("preserves bypass capability across query restarts triggered by thinking ch
|
||||
const session = await createSession();
|
||||
|
||||
try {
|
||||
await session.setMode("bypassPermissions");
|
||||
await session.setMode("acceptEdits");
|
||||
await session.setThinkingOption("high");
|
||||
await session.setMode("bypassPermissions");
|
||||
await collectUntilTerminal(streamSession(session, "say exactly hello"));
|
||||
|
||||
expect(capturedOptions).toHaveLength(2);
|
||||
expect(capturedOptions).toHaveLength(1);
|
||||
expect(capturedOptions[0]).toMatchObject({
|
||||
permissionMode: "default",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
});
|
||||
expect(capturedOptions[1]).toMatchObject({
|
||||
permissionMode: "acceptEdits",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
effort: "high",
|
||||
});
|
||||
expect(capturedOptions[0]).not.toHaveProperty("allowDangerouslySkipPermissions");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
@@ -432,6 +432,29 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("exposes Ultracode only on Claude models that support it", async () => {
|
||||
const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-"));
|
||||
try {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
configDir: emptyConfigDir,
|
||||
});
|
||||
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
||||
const getThinkingIds = (modelId: string) => {
|
||||
return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id);
|
||||
};
|
||||
|
||||
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-7")).not.toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
|
||||
} finally {
|
||||
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClaudeAgentClient binary resolution", () => {
|
||||
@@ -526,12 +549,26 @@ describe("ClaudeAgentSession features", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
function createQueryMock() {
|
||||
const queryReturn = vi.fn(async () => undefined);
|
||||
let endQuery: (() => void) | null = null;
|
||||
const queryEnded = new Promise<void>((resolve) => {
|
||||
endQuery = resolve;
|
||||
});
|
||||
const queryReturn = vi.fn(async () => {
|
||||
endQuery?.();
|
||||
});
|
||||
const queryMock = {
|
||||
close: vi.fn(),
|
||||
return: queryReturn,
|
||||
applyFlagSettings: vi.fn(async () => undefined),
|
||||
setModel: vi.fn(async () => undefined),
|
||||
[Symbol.asyncIterator](): AsyncIterator<SDKMessage, void> {
|
||||
return {
|
||||
next: async () => {
|
||||
await queryEnded;
|
||||
return { value: undefined, done: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const queryFactory = vi.fn(() => queryMock);
|
||||
return { queryFactory, queryMock };
|
||||
@@ -585,6 +622,58 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("maps Ultracode to xhigh effort and Claude ultracode settings", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
thinkingOptionId: "ultracode",
|
||||
});
|
||||
|
||||
await expect(session.startTurn("hello")).resolves.toEqual({
|
||||
turnId: expect.stringMatching(/^foreground-turn-/),
|
||||
});
|
||||
|
||||
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
|
||||
effort: "xhigh",
|
||||
thinking: { type: "adaptive" },
|
||||
settings: { ultracode: true },
|
||||
});
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("returns a next-turn notice when changing Claude thinking during an active turn", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
});
|
||||
|
||||
await expect(session.startTurn("hello")).resolves.toEqual({
|
||||
turnId: expect.stringMatching(/^foreground-turn-/),
|
||||
});
|
||||
|
||||
await expect(session.setThinkingOption?.("ultracode")).resolves.toEqual({
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
});
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("toggles fast mode on the active query without restarting it", async () => {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||
import {
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
@@ -46,6 +47,7 @@ import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./quer
|
||||
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionUpdate,
|
||||
type AgentPersistenceHandle,
|
||||
type AgentProviderNotice,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
@@ -361,6 +364,7 @@ interface ClaudeAgentSessionOptions {
|
||||
}
|
||||
|
||||
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
type ClaudeThinkingOption = ClaudeThinkingEffort | "ultracode";
|
||||
|
||||
function resolvePathEnvKey(): "Path" | "PATH" | null {
|
||||
if (process.env["Path"] !== undefined) return "Path";
|
||||
@@ -407,6 +411,10 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau
|
||||
);
|
||||
}
|
||||
|
||||
function isClaudeThinkingOption(value: string | null | undefined): value is ClaudeThinkingOption {
|
||||
return value === "ultracode" || isClaudeThinkingEffort(value);
|
||||
}
|
||||
|
||||
interface ClaudeOptionsLogSummary {
|
||||
cwd: string | null;
|
||||
permissionMode: string | null;
|
||||
@@ -1494,6 +1502,9 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("Claude Code", [
|
||||
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||
knownBinaryNames: ["claude"],
|
||||
})),
|
||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||
...(auth ? [{ label: "Auth", value: auth }] : []),
|
||||
{ label: "Models", value: modelsValue },
|
||||
@@ -1962,7 +1973,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.persistence = null;
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
|
||||
const normalizedThinkingOptionId =
|
||||
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
||||
? thinkingOptionId
|
||||
@@ -1970,12 +1981,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
|
||||
this.config.thinkingOptionId = undefined;
|
||||
} else if (isClaudeThinkingEffort(normalizedThinkingOptionId)) {
|
||||
} else if (isClaudeThinkingOption(normalizedThinkingOptionId)) {
|
||||
this.config.thinkingOptionId = normalizedThinkingOptionId;
|
||||
} else {
|
||||
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
|
||||
}
|
||||
this.queryRestartNeeded = true;
|
||||
if (this.activeForegroundTurnId || this.autonomousTurn) {
|
||||
return SETTING_APPLIES_NEXT_TURN_NOTICE;
|
||||
}
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
@@ -2603,15 +2617,19 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private resolveThinkingConfig(): {
|
||||
thinking: ClaudeOptions["thinking"];
|
||||
effort: ClaudeOptions["effort"];
|
||||
ultracode: boolean;
|
||||
} {
|
||||
const thinkingOptionId =
|
||||
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
|
||||
? this.config.thinkingOptionId
|
||||
: undefined;
|
||||
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
|
||||
return { thinking: { type: "adaptive" }, effort: thinkingOptionId };
|
||||
if (thinkingOptionId === "ultracode") {
|
||||
return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true };
|
||||
}
|
||||
return { thinking: undefined, effort: undefined };
|
||||
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
|
||||
return { thinking: { type: "adaptive" }, effort: thinkingOptionId, ultracode: false };
|
||||
}
|
||||
return { thinking: undefined, effort: undefined, ultracode: false };
|
||||
}
|
||||
|
||||
private buildAppendedSystemPrompt(): string {
|
||||
@@ -2637,10 +2655,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private async buildOptions(): Promise<ClaudeOptions> {
|
||||
const { thinking, effort } = this.resolveThinkingConfig();
|
||||
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
|
||||
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
||||
const extraClaudeOptions = this.config.extra?.claude;
|
||||
const fastModeOptions = this.buildFastModeOptions(extraClaudeOptions);
|
||||
const settingsOptions = this.buildSettingsOptions(extraClaudeOptions, { ultracode });
|
||||
const sdkEnv = this.buildSdkEnv(extraClaudeOptions);
|
||||
assertClaudeAutoModeEligible(this.currentMode, sdkEnv);
|
||||
|
||||
@@ -2666,10 +2684,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
cwd: this.config.cwd,
|
||||
includePartialMessages: true,
|
||||
permissionMode: this.currentMode,
|
||||
// Dynamic mode switching can recreate the underlying Claude query. Keep the
|
||||
// bypass launch capability available so later setPermissionMode("bypassPermissions")
|
||||
// calls do not fail after a model/thinking/rewind-driven restart.
|
||||
allowDangerouslySkipPermissions: true,
|
||||
agents: this.defaults?.agents,
|
||||
canUseTool: this.handlePermissionRequest,
|
||||
pathToClaudeCodeExecutable: claudeBinary,
|
||||
@@ -2693,7 +2707,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...extraClaudeOptions,
|
||||
...fastModeOptions,
|
||||
...settingsOptions,
|
||||
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
|
||||
env: sdkEnv,
|
||||
};
|
||||
@@ -2718,14 +2732,20 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return base;
|
||||
}
|
||||
|
||||
private buildFastModeOptions(
|
||||
private buildSettingsOptions(
|
||||
extraClaudeOptions: Partial<ClaudeOptions> | undefined,
|
||||
input: { ultracode: boolean },
|
||||
): Pick<ClaudeOptions, "settings"> | Record<string, never> {
|
||||
const fastMode = this.resolveFastModeSetting();
|
||||
if (fastMode === null) {
|
||||
if (fastMode === null && !input.ultracode) {
|
||||
return {};
|
||||
}
|
||||
return { settings: mergeClaudeSettings(extraClaudeOptions?.settings, { fastMode }) };
|
||||
return {
|
||||
settings: mergeClaudeSettings(extraClaudeOptions?.settings, {
|
||||
...(fastMode === null ? {} : { fastMode }),
|
||||
...(input.ultracode ? { ultracode: true } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveFastModeSetting(): boolean | null {
|
||||
|
||||
@@ -20,20 +20,25 @@ const CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS = [
|
||||
{ id: "max", label: "Max" },
|
||||
] as const;
|
||||
|
||||
const CLAUDE_ULTRACODE_THINKING_OPTIONS = [
|
||||
...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS,
|
||||
{ id: "ultracode", label: "Ultracode" },
|
||||
] as const;
|
||||
|
||||
const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-fable-5",
|
||||
label: "Fable 5",
|
||||
description: "Fable 5 · Most powerful model",
|
||||
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
|
||||
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-8[1m]",
|
||||
label: "Opus 4.8 1M",
|
||||
description: "Opus 4.8 with 1M context window",
|
||||
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
|
||||
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
@@ -41,7 +46,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
label: "Opus 4.8",
|
||||
description: "Opus 4.8 · Latest release",
|
||||
isDefault: true,
|
||||
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
|
||||
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
|
||||
@@ -338,6 +338,24 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("setMode and setThinkingOption return a next-turn notice while a turn is active", async () => {
|
||||
const session = createSession({ modeId: "auto", thinkingOptionId: "medium" });
|
||||
|
||||
await expect(session.setMode("full-access")).resolves.toEqual({
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
});
|
||||
await expect(session.setThinkingOption?.("high")).resolves.toEqual({
|
||||
type: "info",
|
||||
message: "This change applies next turn.",
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
|
||||
await expect(session.setMode("auto")).resolves.toBeUndefined();
|
||||
await expect(session.setThinkingOption?.("low")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test.each(["auto_review", "guardian_subagent"])(
|
||||
"parses %s thread/start response as auto-review mode",
|
||||
async (approvalsReviewer) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type AgentPermissionRequest,
|
||||
type AgentPermissionResponse,
|
||||
type AgentPermissionResult,
|
||||
type AgentProviderNotice,
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentRunOptions,
|
||||
@@ -87,10 +88,12 @@ import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../provider-notices.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
|
||||
function assertChildWithPipes(
|
||||
@@ -3653,10 +3656,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return this.currentMode ?? null;
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
async setMode(modeId: string): Promise<void | AgentProviderNotice> {
|
||||
validateCodexMode(modeId);
|
||||
this.currentMode = modeId;
|
||||
this.cachedRuntimeInfo = null;
|
||||
if (this.activeForegroundTurnId) {
|
||||
return SETTING_APPLIES_NEXT_TURN_NOTICE;
|
||||
}
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
@@ -3668,10 +3674,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
|
||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId);
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
if (this.activeForegroundTurnId) {
|
||||
return SETTING_APPLIES_NEXT_TURN_NOTICE;
|
||||
}
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
@@ -5606,6 +5615,9 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
const availability = await checkCodexLaunchAvailable(launch);
|
||||
const available = availability.available;
|
||||
const entries: Array<{ label: string; value: string }> = [
|
||||
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||
knownBinaryNames: ["codex"],
|
||||
})),
|
||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||
];
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
@@ -127,6 +128,9 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("Copilot", [
|
||||
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||
knownBinaryNames: ["copilot"],
|
||||
})),
|
||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
|
||||
@@ -1,6 +1,131 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { toDiagnosticErrorMessage } from "./diagnostic-utils.js";
|
||||
import {
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "paseo-diagnostic-path-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeExecutable(dir: string, name: string): string {
|
||||
const filePath = path.join(dir, name);
|
||||
writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(filePath, 0o755);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe("buildCommandResolutionDiagnosticRows", () => {
|
||||
test("reports daemon PATH matches for known binary names", async () => {
|
||||
const binDir = makeTempDir();
|
||||
const binaryPath = writeExecutable(binDir, "claude");
|
||||
|
||||
const rows = await buildCommandResolutionDiagnosticRows(
|
||||
{ command: "claude", args: [], source: "default" },
|
||||
{
|
||||
knownBinaryNames: ["claude"],
|
||||
pathValue: binDir,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rows).toContainEqual({ label: "Command source", value: "default" });
|
||||
expect(rows).toContainEqual({ label: "Configured command", value: "claude" });
|
||||
expect(rows).toContainEqual({
|
||||
label: "Daemon PATH",
|
||||
value: expect.stringContaining(binDir),
|
||||
});
|
||||
expect(rows).toContainEqual({
|
||||
label: "PATH matches",
|
||||
value: expect.stringContaining(binaryPath),
|
||||
});
|
||||
if (process.platform === "win32") {
|
||||
expect(rows.some((row) => row.label === "where.exe claude")).toBe(true);
|
||||
expect(rows.some((row) => row.label === "powershell Get-Command -All claude")).toBe(true);
|
||||
} else {
|
||||
expect(rows.some((row) => row.label === "which -a claude")).toBe(true);
|
||||
expect(rows.some((row) => row.label.endsWith(" -lc type -a claude"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("reports none when the daemon PATH has no matching executable", async () => {
|
||||
const binDir = makeTempDir();
|
||||
|
||||
const rows = await buildCommandResolutionDiagnosticRows(
|
||||
{ command: "claude", args: [], source: "default" },
|
||||
{ knownBinaryNames: ["claude"], includeCommandProbes: false, pathValue: binDir },
|
||||
);
|
||||
|
||||
expect(rows).toContainEqual({ label: "PATH matches", value: "none" });
|
||||
});
|
||||
|
||||
test("truncates very long daemon PATH values", async () => {
|
||||
const rows = await buildCommandResolutionDiagnosticRows(
|
||||
{ command: "claude", args: [], source: "default" },
|
||||
{
|
||||
knownBinaryNames: ["claude"],
|
||||
includeCommandProbes: false,
|
||||
pathValue: "x".repeat(5000),
|
||||
},
|
||||
);
|
||||
|
||||
expect(rows).toContainEqual({
|
||||
label: "Daemon PATH",
|
||||
value: expect.stringContaining("(truncated)"),
|
||||
});
|
||||
});
|
||||
|
||||
test("matches Windows PATHEXT executable names", async () => {
|
||||
const binDir = makeTempDir();
|
||||
const binaryPath = writeExecutable(binDir, "claude.EXE");
|
||||
|
||||
const rows = await buildCommandResolutionDiagnosticRows(
|
||||
{ command: "claude", args: [], source: "default" },
|
||||
{
|
||||
knownBinaryNames: ["claude"],
|
||||
includeCommandProbes: false,
|
||||
pathext: ".EXE;.CMD",
|
||||
pathValue: binDir,
|
||||
platform: "win32",
|
||||
},
|
||||
);
|
||||
|
||||
expect(rows).toContainEqual({
|
||||
label: "PATH matches",
|
||||
value: expect.stringContaining(binaryPath),
|
||||
});
|
||||
});
|
||||
|
||||
test("does not treat absolute configured commands as PATH binary names", async () => {
|
||||
const rows = await buildCommandResolutionDiagnosticRows(
|
||||
{ command: "/Users/mn/.local/bin/claude", args: [], source: "override" },
|
||||
{
|
||||
knownBinaryNames: ["/Users/mn/.local/bin/claude"],
|
||||
includeCommandProbes: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rows).toContainEqual({ label: "PATH matches", value: "not checked" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDiagnosticErrorMessage", () => {
|
||||
test("returns message for plain Error", () => {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
createProviderEnvSpec,
|
||||
type ProviderLaunchAvailability,
|
||||
@@ -154,6 +158,232 @@ export interface BinaryDiagnosticRowsOptions {
|
||||
versionCommand?: BinaryDiagnosticVersionCommand;
|
||||
}
|
||||
|
||||
export interface CommandResolutionDiagnosticRowsOptions {
|
||||
knownBinaryNames: readonly string[];
|
||||
includeCommandProbes?: boolean;
|
||||
pathValue?: string;
|
||||
pathext?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
shell?: string;
|
||||
}
|
||||
|
||||
const COMMAND_PROBE_TIMEOUT_MS = 3_000;
|
||||
const COMMAND_PROBE_MAX_BUFFER = 32 * 1024;
|
||||
|
||||
function resolvePlatform(options?: CommandResolutionDiagnosticRowsOptions): NodeJS.Platform {
|
||||
return options?.platform ?? process.platform;
|
||||
}
|
||||
|
||||
function resolvePathValue(options?: CommandResolutionDiagnosticRowsOptions): string {
|
||||
return options?.pathValue ?? process.env["PATH"] ?? process.env["Path"] ?? "";
|
||||
}
|
||||
|
||||
function resolveShellValue(options?: CommandResolutionDiagnosticRowsOptions): string {
|
||||
if (options?.shell) {
|
||||
return options.shell;
|
||||
}
|
||||
if (resolvePlatform(options) === "win32") {
|
||||
return process.env["ComSpec"] ?? "cmd.exe";
|
||||
}
|
||||
return process.env["SHELL"] ?? "/bin/sh";
|
||||
}
|
||||
|
||||
async function isExecutableFile(filePath: string, platform: NodeJS.Platform): Promise<boolean> {
|
||||
try {
|
||||
const candidate = await stat(filePath);
|
||||
if (!candidate.isFile()) {
|
||||
return false;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return true;
|
||||
}
|
||||
await access(filePath, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSearchableNames(binaryNames: readonly string[]): string[] {
|
||||
return binaryNames.filter(
|
||||
(binaryName) =>
|
||||
binaryName.trim().length > 0 && !binaryName.includes("/") && !binaryName.includes("\\"),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWindowsPathExt(options: CommandResolutionDiagnosticRowsOptions): string[] {
|
||||
const value = options.pathext ?? process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD";
|
||||
return value
|
||||
.split(";")
|
||||
.map((extension) => extension.trim())
|
||||
.filter(Boolean)
|
||||
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`));
|
||||
}
|
||||
|
||||
function resolveBinaryCandidateNames(
|
||||
binaryName: string,
|
||||
options: CommandResolutionDiagnosticRowsOptions,
|
||||
): string[] {
|
||||
if (resolvePlatform(options) !== "win32" || path.win32.extname(binaryName)) {
|
||||
return [binaryName];
|
||||
}
|
||||
return [binaryName, ...resolveWindowsPathExt(options).map((extension) => binaryName + extension)];
|
||||
}
|
||||
|
||||
async function formatPathMatches(options: CommandResolutionDiagnosticRowsOptions): Promise<string> {
|
||||
const binaryNames = options.knownBinaryNames;
|
||||
const searchableNames = resolveSearchableNames(binaryNames);
|
||||
|
||||
if (searchableNames.length === 0) {
|
||||
return "not checked";
|
||||
}
|
||||
|
||||
const pathDelimiter = resolvePlatform(options) === "win32" ? ";" : path.delimiter;
|
||||
const pathEntries = resolvePathValue(options).split(pathDelimiter).filter(Boolean);
|
||||
const matches: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const platform = resolvePlatform(options);
|
||||
|
||||
for (const directory of pathEntries) {
|
||||
for (const binaryName of searchableNames) {
|
||||
for (const candidateName of resolveBinaryCandidateNames(binaryName, options)) {
|
||||
const candidate = path.join(directory, candidateName);
|
||||
if (seen.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate);
|
||||
if (await isExecutableFile(candidate, platform)) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches.length > 0 ? matches.join("\n ") : "none";
|
||||
}
|
||||
|
||||
function shellToken(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function formatCommandProbeOutput(stdout: string, stderr: string): string {
|
||||
const sections: string[] = [];
|
||||
const trimmedStdout = truncateForDiagnostic(stdout);
|
||||
const trimmedStderr = truncateForDiagnostic(stderr);
|
||||
if (trimmedStdout.length > 0) {
|
||||
sections.push(trimmedStdout);
|
||||
}
|
||||
if (trimmedStderr.length > 0) {
|
||||
sections.push(`stderr: ${trimmedStderr}`);
|
||||
}
|
||||
return sections.length > 0 ? sections.join("\n") : "(no output)";
|
||||
}
|
||||
|
||||
function formatCommandProbeError(error: unknown): string {
|
||||
return toDiagnosticErrorMessage(error);
|
||||
}
|
||||
|
||||
async function runCommandProbe(command: string, args: string[]): Promise<string> {
|
||||
try {
|
||||
const { stdout, stderr } = await execCommand(command, args, {
|
||||
timeout: COMMAND_PROBE_TIMEOUT_MS,
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: COMMAND_PROBE_MAX_BUFFER,
|
||||
});
|
||||
return formatCommandProbeOutput(stdout, stderr);
|
||||
} catch (error) {
|
||||
return formatCommandProbeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPosixCommandProbeRows(binaryName: string): Promise<DiagnosticEntry[]> {
|
||||
const shell = resolveShellValue();
|
||||
const typeCommand = `type -a ${shellToken(binaryName)}`;
|
||||
return [
|
||||
{
|
||||
label: `which -a ${binaryName}`,
|
||||
value: await runCommandProbe("/usr/bin/which", ["-a", binaryName]),
|
||||
},
|
||||
{
|
||||
label: `${path.basename(shell)} -lc type -a ${binaryName}`,
|
||||
value: await runCommandProbe(shell, ["-lc", typeCommand]),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function buildWindowsCommandProbeRows(binaryName: string): Promise<DiagnosticEntry[]> {
|
||||
const powershellCommand = [
|
||||
"$ErrorActionPreference = 'Continue';",
|
||||
`Get-Command -All ${JSON.stringify(binaryName)} |`,
|
||||
"Select-Object CommandType,Source,Name,Definition |",
|
||||
"Format-List",
|
||||
].join(" ");
|
||||
|
||||
return [
|
||||
{
|
||||
label: `where.exe ${binaryName}`,
|
||||
value: await runCommandProbe("where.exe", [binaryName]),
|
||||
},
|
||||
{
|
||||
label: `powershell Get-Command -All ${binaryName}`,
|
||||
value: await runCommandProbe("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
powershellCommand,
|
||||
]),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function buildCommandProbeRows(binaryNames: readonly string[]): Promise<DiagnosticEntry[]> {
|
||||
const searchableNames = resolveSearchableNames(binaryNames);
|
||||
if (searchableNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows: DiagnosticEntry[] = [];
|
||||
for (const binaryName of searchableNames) {
|
||||
rows.push(
|
||||
...(process.platform === "win32"
|
||||
? await buildWindowsCommandProbeRows(binaryName)
|
||||
: await buildPosixCommandProbeRows(binaryName)),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function buildCommandResolutionDiagnosticRows(
|
||||
launch: ResolvedProviderLaunch,
|
||||
options: CommandResolutionDiagnosticRowsOptions,
|
||||
): Promise<DiagnosticEntry[]> {
|
||||
const includeCommandProbes = options.includeCommandProbes ?? true;
|
||||
return [
|
||||
{
|
||||
label: "Command source",
|
||||
value: launch.source,
|
||||
},
|
||||
{
|
||||
label: "Configured command",
|
||||
value: [launch.command, ...launch.args].join(" "),
|
||||
},
|
||||
{
|
||||
label: "Daemon PATH",
|
||||
value: truncateForDiagnostic(resolvePathValue(options)) || "(empty)",
|
||||
},
|
||||
{
|
||||
label: "Daemon shell",
|
||||
value: resolveShellValue(options),
|
||||
},
|
||||
{
|
||||
label: "PATH matches",
|
||||
value: await formatPathMatches(options),
|
||||
},
|
||||
...(includeCommandProbes ? await buildCommandProbeRows(options.knownBinaryNames) : []),
|
||||
];
|
||||
}
|
||||
|
||||
async function resolveCommandVersion(invocation: BinaryDiagnosticVersionCommand): Promise<string> {
|
||||
try {
|
||||
const { stdout, stderr } = await execCommand(invocation.command, invocation.args, {
|
||||
|
||||
@@ -35,7 +35,7 @@ export class MockSlowProviderClient implements AgentClient {
|
||||
readonly capabilities = CAPABILITIES;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
return process.env.PASEO_ENABLE_MOCK_SLOW === "true";
|
||||
}
|
||||
|
||||
listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user