Compare commits

..
Author SHA1 Message Date
Mohamed Boudra bf4afc3634 fix(settings): show usage limits approaching exhaustion
Provider status tones could hide utilization risk. Display the strongest signal so high usage reaches warning and danger while provider-specific alerts remain visible.
2026-07-22 13:16:53 +00:00
174 changed files with 2741 additions and 5725 deletions
-22
View File
@@ -1,27 +1,5 @@
# Changelog
## 0.2.0-beta.3 - 2026-07-22
### Added
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
- Use Oh My Pi's Write Approval mode to allow reads while requiring approval for changes ([#2228](https://github.com/getpaseo/paseo/pull/2228) by [@theslava](https://github.com/theslava))
### Improved
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
- Oh My Pi advisor results now retain their structure and severity ([#2219](https://github.com/getpaseo/paseo/pull/2219) by [@ebg1223](https://github.com/ebg1223))
### Fixed
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
- Oh My Pi slash commands now include commands from every supported source ([#2175](https://github.com/getpaseo/paseo/pull/2175) by [@bendavid](https://github.com/bendavid))
- Oh My Pi chats no longer stay stuck as running after delayed or incomplete completion events ([#2261](https://github.com/getpaseo/paseo/pull/2261), [#2282](https://github.com/getpaseo/paseo/pull/2282) by [@isac322](https://github.com/isac322))
## 0.2.0-beta.2 - 2026-07-22
### Added
-6
View File
@@ -390,7 +390,6 @@ npm run cli -- ls -a -g # List all agents globally
npm run cli -- ls -a -g --json # Same, as JSON
npm run cli -- inspect <id> # Show detailed agent info
npm run cli -- logs <id> # View agent timeline
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
npm run cli -- daemon status # Check daemon status
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
```
@@ -401,11 +400,6 @@ Use `--host <host:port>` to point the CLI at a different daemon:
npm run cli -- --host localhost:7777 ls -a
```
Desktop integrations can focus an existing agent without creating one or
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
default; pass `--server <server-id>` when targeting another server.
## Agent state
Agent data lives at:
-14
View File
@@ -73,20 +73,6 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Agent Targets
Notifications and agent URLs enter the router with different authoritative
targets.
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
directly to the workspace with the agent open intent.
- Agent URLs carry only `serverId` and `agentId`. Route them through
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
the agent's workspace from the host, and then opens the agent there.
Both paths converge on `navigateToAgent()`. Do not make notification routing
guess a workspace, and do not add a workspace to the stable agent URL format.
## Params
Required dynamic params belong to the matched route.
+1 -1
View File
@@ -1 +1 @@
sha256-x6jiaw7zkCuBRlddLHD0tXQxolXwr7tzIQBfWu5xnUU=
sha256-Ys/R5+0O3SDYeCSd7hatTuCwV9S0kIO8vhmPKGZsvn0=
+21 -21
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35211,7 +35211,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36236,12 +36236,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/server": "0.2.0-beta.3",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/server": "0.2.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36487,10 +36487,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/relay": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36501,7 +36501,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36744,7 +36744,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37640,7 +37640,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37872,7 +37872,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37885,7 +37885,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38103,15 +38103,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.0-beta.3",
"@getpaseo/highlight": "0.2.0-beta.3",
"@getpaseo/protocol": "0.2.0-beta.3",
"@getpaseo/relay": "0.2.0-beta.3",
"@getpaseo/client": "0.2.0-beta.2",
"@getpaseo/highlight": "0.2.0-beta.2",
"@getpaseo/protocol": "0.2.0-beta.2",
"@getpaseo/relay": "0.2.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38648,7 +38648,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,49 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect, type Page } from "./fixtures";
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
function visibleComposer(page: Page) {
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
}
test("adds a changed file to the focused chat without replacing its composer draft", async ({
page,
}) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "add-file-to-chat-",
title: "Target chat",
});
const relativePath = "src/changed file.ts";
try {
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
await workspace.client.checkoutRefresh(workspace.cwd);
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const agentComposer = visibleComposer(page);
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
await agentComposer.fill("Preserve this thought");
await page.getByRole("button", { name: "Open explorer" }).click();
await page.getByTestId("explorer-tab-changes").click();
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
await expect(changedFile).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-add-to-chat").click();
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
await expect(attachment).toContainText("changed file.ts");
await expect(attachment).toContainText(relativePath);
await expect(agentComposer).toHaveValue("Preserve this thought");
await expect(agentComposer).toBeFocused();
} finally {
await workspace.cleanup();
}
});
+12 -6
View File
@@ -28,20 +28,26 @@ test("commit history shows dates and shares diff layout preferences", async ({
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await panel.getByTestId("commit-diff-layout-split").click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
"Switch to unified diff",
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
+6 -178
View File
@@ -1,4 +1,4 @@
import { unlink, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
@@ -10,11 +10,6 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
repoPath: string;
}
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
}
interface CleanupTask {
@@ -223,99 +218,6 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const changesTabToggle = page.getByTestId("changes-open-tab");
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
await changesTabToggle.click();
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
await page.getByTestId("working-diff-toggle-wrap-lines").click();
await visiblePanel.getByTestId("working-diff-options-menu").click();
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
"Scroll long lines",
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(visiblePanel).toContainText("zz-deleted.ts");
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
"zz-deleted.ts",
);
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -341,11 +243,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -353,53 +250,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await expectFlatFileList(page);
});
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -549,14 +399,10 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
});
}
async function createWorkspaceWithMountedTabDiff(
options: WorkspaceFixtureOptions = {},
): Promise<DirtyWorkspace> {
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
const repo = await createTempGitRepo("diff-row-alignment-", { files });
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
const repo = await createTempGitRepo("diff-row-alignment-", {
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
});
const client = await connectSeedClient();
cleanupTasks.push({
run: async () => {
@@ -566,16 +412,13 @@ async function createWorkspaceWithMountedTabDiff(
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
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: createdWorkspace.workspace.id, repoPath: repo.path };
return { id: createdWorkspace.workspace.id };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
@@ -593,21 +436,6 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
@@ -1,8 +1,4 @@
import {
buildHostAgentDetailRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import {
@@ -38,7 +34,6 @@ import { getServerId } from "./helpers/server-id";
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
import { expectAppRoute } from "./helpers/route-assertions";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { addOfflineHostAndReload } from "./helpers/hosts";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
type StartupPresentation = "splash" | "app";
@@ -163,43 +158,6 @@ async function expectWorkspaceLocation(
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("opens a notification's workspace on a different offline host", async ({ page }) => {
const target = {
serverId: "notification-offline-host",
workspaceId: "notification-workspace",
agentId: "notification-agent",
};
await gotoAppShell(page);
await addOfflineHostAndReload(page, {
serverId: target.serverId,
label: "Notification Host",
});
await expect(
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
).toBeVisible({
timeout: 30_000,
});
await page.evaluate((data) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: { data: { ...data, reason: "finished" } },
cancelable: true,
}),
);
}, target);
await expectAppRoute(
page,
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
{ timeout: 30_000 },
);
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
});
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
page,
withWorkspace,
@@ -462,13 +420,12 @@ test.describe("Workspace navigation regression", () => {
await expectWorkspaceDeckEntryCount(page, 2);
await page.evaluate(
({ agentId, serverId: targetServerId, workspaceId }) => {
({ agentId, serverId: targetServerId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId: targetServerId,
workspaceId,
agentId,
reason: "finished",
},
@@ -477,7 +434,7 @@ test.describe("Workspace navigation regression", () => {
}),
);
},
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
{ agentId: secondAgent.id, serverId },
);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.0-beta.3",
"version": "0.2.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -257,35 +257,6 @@ describe("bottom anchor controller driver", () => {
expect(harness.scrollToBottom).not.toHaveBeenCalled();
});
it("pauses sticky maintenance while a user scroll owns the viewport", () => {
const harness = createDriverHarness({
transportBehavior: {
verificationDelayFrames: 2,
verificationRetryMode: "recheck",
},
});
harness.driver.prepareForStickyContentChange();
harness.driver.beginUserScroll();
harness.driver.handleContentSizeChange({
previousContentHeight: 1200,
contentHeight: 1400,
});
harness.context.nearBottom = false;
harness.driver.handleScrollNearBottomChange({
nextIsNearBottom: false,
scrollDelta: 1,
});
harness.scheduler.flushAll();
expect(harness.scrollToBottom).toHaveBeenCalledTimes(1);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "detached",
pendingRequest: null,
pendingVerification: null,
});
});
it("switches back to sticky-bottom for explicit jump-to-bottom", () => {
const harness = createDriverHarness({
isNearBottom: false,
@@ -68,8 +68,6 @@ interface BottomAnchorControllerDriver {
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
endUserScroll: () => void;
detachByUser: () => void;
handleViewportMetricsChange: (params: {
previousViewportWidth: number;
@@ -245,7 +243,6 @@ function createBottomAnchorControllerDriver(
let lastRouteRequestKey: string | null = null;
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
let isUserScrollActive = false;
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
if (blockedReason === nextBlockedReason) {
@@ -484,7 +481,6 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
@@ -501,21 +497,6 @@ function createBottomAnchorControllerDriver(
requestLocalAnchor(request) {
createRequest(request);
},
beginUserScroll() {
isUserScrollActive = true;
cancelPendingRequest("user_scroll_started");
},
endUserScroll() {
isUserScrollActive = false;
if (mode !== "sticky-bottom") {
return;
}
if (input.isNearBottom()) {
markStickyMeasurementVerified();
return;
}
this.detachByUser();
},
detachByUser() {
if (mode === "detached") {
return;
@@ -530,9 +511,6 @@ function createBottomAnchorControllerDriver(
) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnViewportChange({
mode,
previousViewportWidth: params.previousViewportWidth,
@@ -551,9 +529,6 @@ function createBottomAnchorControllerDriver(
if (params.previousContentHeight !== params.contentHeight) {
markStickyMeasurementChanged();
}
if (isUserScrollActive) {
return;
}
const shouldRestick = __private__.shouldRestickOnContentChange({
mode,
previousContentHeight: params.previousContentHeight,
@@ -583,9 +558,6 @@ function createBottomAnchorControllerDriver(
return;
}
markStickyMeasurementChanged();
if (isUserScrollActive) {
return;
}
if (!pendingRequest) {
pendingVerification = { requestId: null, retries: 0 };
if (attemptHandle) {
@@ -599,12 +571,6 @@ function createBottomAnchorControllerDriver(
},
handleScrollNearBottomChange(params) {
const { nextIsNearBottom, scrollDelta } = params;
if (isUserScrollActive) {
if (mode === "sticky-bottom" && !nextIsNearBottom) {
this.detachByUser();
}
return;
}
if (
nextIsNearBottom &&
mode === "sticky-bottom" &&
@@ -771,12 +737,6 @@ export function useBottomAnchorController(input: {
requestLocalAnchor(request: BottomAnchorLocalRequest) {
driverRef.current?.requestLocalAnchor(request);
},
beginUserScroll() {
driverRef.current?.beginUserScroll();
},
endUserScroll() {
driverRef.current?.endUserScroll();
},
detachByUser() {
driverRef.current?.detachByUser();
},
@@ -89,8 +89,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null as string | null,
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
@@ -131,13 +129,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
}, []);
const clearPendingUserScrollEnd = useCallback(() => {
if (userScrollEndFrameIdRef.current !== null) {
cancelAnimationFrame(userScrollEndFrameIdRef.current);
userScrollEndFrameIdRef.current = null;
}
}, []);
const markNativeViewportSettling = useCallback(() => {
clearNativeViewportSettling();
setIsNativeViewportSettling(true);
@@ -217,8 +208,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
@@ -227,9 +216,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
return () => {
cancelAnimationFrame(frame);
clearPendingUserScrollEnd();
};
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
const keyboardEvents = [
@@ -313,11 +301,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onNearHistoryStart();
}
if (
!isUserScrollActiveRef.current &&
programmaticScrollEventBudgetRef.current > 0 &&
contentOffset.y <= 8
) {
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
programmaticScrollEventBudgetRef.current = 0;
@@ -328,31 +312,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
const handleScrollBeginDrag = useStableEvent(() => {
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
bottomAnchorController.beginUserScroll();
});
const handleScrollEndDrag = useStableEvent(() => {
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll();
});
});
const handleMomentumScrollBegin = useStableEvent(() => {
clearPendingUserScrollEnd();
});
const handleMomentumScrollEnd = useStableEvent(() => {
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = false;
bottomAnchorController.endUserScroll();
});
const handleListLayout = useStableEvent((event: LayoutChangeEvent) => {
const previousViewportWidth = streamViewportMetricsRef.current.viewportWidth;
const previousViewportHeight = streamViewportMetricsRef.current.viewportHeight;
@@ -460,10 +419,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
style={listStyle}
onLayout={handleListLayout}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEndDrag}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
+1 -1
View File
@@ -99,7 +99,7 @@ import {
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { toErrorMessage } from "@/utils/error-messages";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
+2 -5
View File
@@ -66,7 +66,6 @@ import {
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
@@ -147,10 +146,9 @@ function PushNotificationRouter() {
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data);
const serverId = target.serverId;
const workspaceId = target.workspaceId;
const agentId = target.agentId;
if (serverId && workspaceId && agentId) {
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
if (serverId && agentId) {
navigateToAgent({ serverId, agentId, pin: true });
return;
}
@@ -910,7 +908,6 @@ function AppShell() {
<MobilePanelsProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AgentNavigationListener />
<AppWithSidebar>
<WorkspaceRouteNavigationBridge />
<RootStack />
@@ -1,13 +1,10 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { toErrorMessage } from "@/utils/error-messages";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
@@ -24,128 +21,97 @@ function HostAgentReadyRouteContent() {
serverId?: string;
agentId?: string;
}>();
const handledNavigationRef = useRef<string | null>(null);
const redirectedRef = useRef(false);
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const agentId = typeof params.agentId === "string" ? params.agentId : "";
const hosts = useHosts();
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
const client = runtimeSnapshot?.client ?? null;
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const lookupQuery = useFetchQuery({
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
queryFn: async () => {
if (!client) {
throw new Error("Target host client is unavailable");
}
const result = await client.fetchAgent({ agentId });
return result?.agent?.workspaceId ?? null;
},
enabled: shouldLookupAgent,
retry: false,
dataShape: "value",
staleTimeMs: 0,
});
const lookup = useMemo<AgentRouteLookup>(() => {
if (!shouldLookupAgent) {
return { kind: "idle" };
}
if (lookupQuery.isFetching) {
return { kind: "fetching" };
}
if (lookupQuery.isError) {
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
}
if (lookupQuery.isSuccess) {
return { kind: "found", workspaceId: lookupQuery.data };
}
return { kind: "fetching" };
}, [
lookupQuery.data,
lookupQuery.error,
lookupQuery.isError,
lookupQuery.isFetching,
lookupQuery.isSuccess,
shouldLookupAgent,
]);
const resolution = resolveAgentRoute({
serverId,
agentId,
cachedWorkspaceId: agentWorkspaceId,
connectionStatus,
lookup,
});
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
useEffect(() => {
let navigationKey: string | null = null;
if (resolution.kind === "invalid") {
navigationKey = "invalid";
} else if (resolution.kind === "resolved") {
navigationKey = `workspace:${resolution.workspaceId}`;
} else if (resolution.kind === "notFound") {
navigationKey = "not-found";
if (redirectedRef.current) {
return;
}
if (!navigationKey || handledNavigationRef.current === navigationKey) {
if (!serverId || !agentId) {
redirectedRef.current = true;
router.replace("/" as Href);
return;
}
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
return;
if (resolvedWorkspaceId) {
redirectedRef.current = true;
navigateToAgent({
serverId,
agentId,
});
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
}, [agentId, resolution, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
const handleRetry = useCallback(() => {
if (resolution.kind === "lookupError") {
void lookupQuery.refetch();
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (serverId) {
void getHostRuntimeStore().runProbeCycleNow(serverId);
}
}, [lookupQuery, resolution.kind, serverId]);
const handleManageHost = useCallback(() => {
if (serverId) {
router.push(buildSettingsHostRoute(serverId));
}
}, [router, serverId]);
const handleBack = useCallback(() => {
if (router.canGoBack()) {
router.back();
if (!serverId || !agentId) {
return;
}
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
}, [router, serverId]);
if (agentWorkspaceId && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
if (
resolution.kind === "waitingForHost" ||
resolution.kind === "fetchingAgent" ||
resolution.kind === "lookupError"
) {
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
// reconnects, then resolve the workspace from the authoritative agent record.
return (
<AgentRouteResolutionView
resolution={resolution}
hostName={hostName}
lastHostError={runtimeSnapshot?.lastError ?? null}
onRetry={handleRetry}
onManageHost={handleManageHost}
onBack={handleBack}
/>
);
}
useEffect(() => {
if (redirectedRef.current) {
return;
}
if (!serverId || !agentId || !client || !isConnected) {
return;
}
let cancelled = false;
void client
.fetchAgent({ agentId })
.then((result) => {
if (cancelled || redirectedRef.current) {
return;
}
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
redirectedRef.current = true;
if (workspaceId) {
navigateToAgent({
serverId,
agentId,
workspaceId,
});
return;
}
router.replace(buildHostRootRoute(serverId));
return;
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
});
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
return null;
}
@@ -9,7 +9,7 @@ import {
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
import {
-11
View File
@@ -92,20 +92,9 @@ export interface ChatHistoryContextAttachment {
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type WorkspaceFileSelection =
| { kind: "whole_file" }
| { kind: "line_range"; startLine: number; endLine: number };
export interface WorkspaceFileComposerAttachment {
kind: "workspace_file";
path: string;
selection: WorkspaceFileSelection;
}
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
@@ -1,9 +0,0 @@
import type { RefCallback } from "react";
import type { View } from "react-native";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource(
_input: WorkspaceFileDragSourceInput,
): RefCallback<View> | undefined {
return undefined;
}
@@ -1,54 +0,0 @@
import { useCallback, useEffect, useState, type RefCallback } from "react";
import type { View } from "react-native";
import { createWorkspaceFileAttachment } from "./workspace-file";
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
export function useWorkspaceFileDragSource({
enabled,
disabled = false,
serverId,
workspaceId,
path,
selection,
}: WorkspaceFileDragSourceInput): RefCallback<View> {
const [element, setElement] = useState<HTMLElement | null>(null);
const dragSourceRef = useCallback((node: View | null) => {
setElement(node as unknown as HTMLElement | null);
}, []);
useEffect(() => {
if (!element || !enabled || disabled || !serverId || !workspaceId) {
if (element) {
element.draggable = false;
}
return;
}
const sourceServerId = serverId;
const sourceWorkspaceId = workspaceId;
element.draggable = true;
function handleDragStart(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
WORKSPACE_FILE_DRAG_MIME,
serializeWorkspaceFileDragPayload({
version: 1,
serverId: sourceServerId,
workspaceId: sourceWorkspaceId,
attachment: createWorkspaceFileAttachment({ path, selection }),
}),
);
}
element.addEventListener("dragstart", handleDragStart);
return () => {
element.draggable = false;
element.removeEventListener("dragstart", handleDragStart);
};
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
return dragSourceRef;
}
@@ -1,10 +0,0 @@
import type { WorkspaceFileSelection } from "./types";
export interface WorkspaceFileDragSourceInput {
enabled: boolean;
disabled?: boolean;
serverId?: string;
workspaceId: string | null | undefined;
path: string;
selection?: WorkspaceFileSelection;
}
@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import { createWorkspaceFileAttachment } from "./workspace-file";
import {
parseWorkspaceFileDragPayload,
resolveWorkspaceFileDrop,
serializeWorkspaceFileDragPayload,
type WorkspaceFileDragPayload,
} from "./workspace-file-drag";
function payload(): WorkspaceFileDragPayload {
return {
version: 1,
serverId: "server-1",
workspaceId: "workspace-1",
attachment: createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
}),
};
}
describe("workspace file drag payload", () => {
it("round-trips workspace identity and future line selections", () => {
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
payload(),
);
});
it("rejects malformed and invalid payloads", () => {
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
expect(
parseWorkspaceFileDragPayload(
JSON.stringify({
...payload(),
attachment: {
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "line_range", startLine: 24, endLine: 12 },
},
}),
),
).toBeNull();
});
it("accepts drops only within the originating server and workspace", () => {
const dragged = payload();
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-1",
}),
).toEqual(dragged.attachment);
expect(
resolveWorkspaceFileDrop({
payload: dragged,
serverId: "server-1",
workspaceId: "workspace-2",
}),
).toBeNull();
});
});
@@ -1,55 +0,0 @@
import type { WorkspaceFileComposerAttachment } from "./types";
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
export interface WorkspaceFileDragPayload {
version: 1;
serverId: string;
workspaceId: string;
attachment: WorkspaceFileComposerAttachment;
}
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
return JSON.stringify(payload);
}
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
let value: unknown;
try {
value = JSON.parse(serialized);
} catch {
return null;
}
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (
record.version !== 1 ||
typeof record.serverId !== "string" ||
record.serverId.length === 0 ||
typeof record.workspaceId !== "string" ||
record.workspaceId.length === 0 ||
!isWorkspaceFileComposerAttachment(record.attachment)
) {
return null;
}
return {
version: 1,
serverId: record.serverId,
workspaceId: record.workspaceId,
attachment: record.attachment,
};
}
export function resolveWorkspaceFileDrop(input: {
payload: WorkspaceFileDragPayload;
serverId: string;
workspaceId: string;
}): WorkspaceFileComposerAttachment | null {
return input.payload.serverId === input.serverId &&
input.payload.workspaceId === input.workspaceId
? input.payload.attachment
: null;
}
@@ -1,71 +0,0 @@
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import {
appendWorkspaceFileAttachment,
createWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
workspaceFileAttachmentToAgentAttachment,
} from "./workspace-file";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
describe("workspace file attachments", () => {
it("models whole files and line ranges as distinct selections", () => {
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const lineRange = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(wholeFile).toEqual({
kind: "workspace_file",
path: "src/app.ts",
selection: { kind: "whole_file" },
});
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
getWorkspaceFileAttachmentKey(lineRange),
);
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
});
it("deduplicates only identical paths and selections", () => {
const image = {
kind: "image" as const,
metadata: {
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "image-1",
createdAt: 1,
},
};
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
const range = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 1, endLine: 5 },
});
const current: UserComposerAttachment[] = [image, wholeFile];
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
});
it("submits a path reference without uploading or inserting prompt text", () => {
const attachment = createWorkspaceFileAttachment({
path: "src/app.ts",
selection: { kind: "line_range", startLine: 12, endLine: 24 },
});
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
type: "text",
mimeType: "text/plain",
title: "app.ts",
text: "Workspace file: src/app.ts\nLines: 12-24",
});
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
images: [],
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
});
});
});
@@ -1,106 +0,0 @@
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type {
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceFileSelection,
} from "./types";
interface CreateWorkspaceFileAttachmentInput {
path: string;
selection?: WorkspaceFileSelection;
}
function normalizePath(path: string): string {
return path.trim().replace(/^\.\//, "");
}
export function isWorkspaceFileComposerAttachment(
value: unknown,
): value is WorkspaceFileComposerAttachment {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (
record.kind !== "workspace_file" ||
typeof record.path !== "string" ||
record.path.trim().length === 0
) {
return false;
}
const selection = record.selection;
if (!selection || typeof selection !== "object") {
return false;
}
const { kind, startLine, endLine } = selection as Record<string, unknown>;
if (kind === "whole_file") {
return true;
}
return (
kind === "line_range" &&
typeof startLine === "number" &&
Number.isInteger(startLine) &&
typeof endLine === "number" &&
Number.isInteger(endLine) &&
startLine > 0 &&
endLine >= startLine
);
}
export function createWorkspaceFileAttachment({
path,
selection = { kind: "whole_file" },
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
return {
kind: "workspace_file",
path: normalizePath(path),
selection,
};
}
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
const selection = attachment.selection;
const selectionKey =
selection.kind === "whole_file"
? selection.kind
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
return `${normalizePath(attachment.path)}:${selectionKey}`;
}
export function appendWorkspaceFileAttachment(
current: UserComposerAttachment[],
attachment: WorkspaceFileComposerAttachment,
): UserComposerAttachment[] {
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
const alreadyAttached = current.some(
(candidate) =>
candidate.kind === "workspace_file" &&
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
);
return alreadyAttached ? current : [...current, attachment];
}
export function workspaceFileAttachmentToAgentAttachment(
attachment: WorkspaceFileComposerAttachment,
): Extract<AgentAttachment, { type: "text" }> {
const fileName = attachment.path.split("/").pop() ?? attachment.path;
const lines =
attachment.selection.kind === "line_range"
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
: "";
return {
type: "text",
mimeType: "text/plain",
title: fileName,
text: `Workspace file: ${attachment.path}${lines}`,
};
}
export function getWorkspaceFileAttachmentSubtitle(
attachment: WorkspaceFileComposerAttachment,
): string {
if (attachment.selection.kind === "whole_file") {
return attachment.path;
}
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
}
@@ -4,8 +4,10 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
import { mountBrowserAutomationHandler } from "./handler";
import type { DesktopHostBridge } from "@/desktop/host";
import { useBrowserStore } from "@/stores/browser-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
@@ -6,8 +6,11 @@ import {
resizeResidentBrowserWebview,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
type BrowserAutomationExecuteRequest = Extract<
SessionOutboundMessage,
@@ -1,5 +1,5 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
export type ClientSlashCommandExecution = "immediate" | "insert";
@@ -1,4 +1,4 @@
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
+2 -3
View File
@@ -4,7 +4,6 @@ import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
testID?: string;
}
const compactFormatter = new Intl.NumberFormat("en-US", {
@@ -16,9 +15,9 @@ export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
export function DiffStat({ additions, deletions }: DiffStatProps) {
return (
<View style={styles.row} testID={testID}>
<View style={styles.row}>
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
</View>
@@ -36,11 +36,6 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -391,16 +386,15 @@ function ExplorerSidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && (
<ChangedFilesPane
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
cwd={workspaceRoot}
enabled={isOpen}
/>
)}
{resolvedTab === "files" && (
<FilesPane
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -421,83 +415,6 @@ function ExplorerSidebarContent({
);
}
/**
* Shared add-to-chat state for the changes/files panes: both expose an "add file
* to chat" action that attaches the file to the focused chat's composer.
* Available only when a workspace with a focused chat is available.
*/
function useAddFileToChat({
serverId,
workspaceId,
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
const focusedChat = useMemo(
() => resolveFocusedChatTarget({ serverId, layout }),
[serverId, layout],
);
const addFile = useCallback(
(filePath: string) => {
if (!focusedChat || !workspaceKey) {
return;
}
void useDraftStore.getState().attachWorkspaceFile({
draftKey: focusedChat.draftKey,
attachment: createWorkspaceFileAttachment({ path: filePath }),
});
focusTab(workspaceKey, focusedChat.tabId);
},
[focusTab, focusedChat, workspaceKey],
);
return { addFile, canAddToChat: focusedChat !== null };
}
function ChangedFilesPane({
serverId,
workspaceId,
workspaceRoot,
isOpen,
onOpenFile,
}: Pick<
SidebarContentProps,
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
function FilesPane({
serverId,
workspaceId,
workspaceRoot,
onOpenFile,
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;
@@ -1,185 +0,0 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Copy,
Download,
FileText,
MessageSquarePlus,
MoreVertical,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
/** Width occupied by a file action trigger, including its visual padding. */
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
interface FileAction {
key: string;
label: string;
icon: LucideIcon;
onSelect: () => void;
testID?: string;
}
interface FileActionsMenuProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onCopyPath?: () => void;
onDownload?: () => void;
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
hitSlop?: number;
accessibilityLabel: string;
testIDPrefix?: string;
}
// The menu lives inside pressable rows (diff header, explorer entry); stop the
// press so opening it doesn't also trigger the row.
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function triggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
}
/**
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
* git diff pane so both surfaces share action availability, ordering, and chrome.
*/
export function FileActionsMenu({
fileKind,
fileExists = true,
onOpenFile,
onCopyPath,
onDownload,
onAddToChat,
header,
open,
onOpenChange,
hitSlop = 12,
accessibilityLabel,
testIDPrefix,
}: FileActionsMenuProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
const next: FileAction[] = [];
if (availableFile && onOpenFile) {
next.push({
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
});
}
if (onCopyPath) {
next.push({
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
});
}
if (availableFile && onDownload) {
next.push({
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
});
}
if (availableFile && onAddToChat) {
next.push({
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
});
}
return next;
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
if (actions.length === 0) {
return null;
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={hitSlop}
onPressIn={stopTriggerPropagation}
style={triggerStyle}
accessibilityLabel={accessibilityLabel}
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
>
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{header ? (
<>
{header}
<DropdownMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
const Icon = action.icon;
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const leading = useMemo(
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
[ThemedIcon],
);
return (
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
{action.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// The hover box comes from padding, but an equal negative vertical margin
// cancels its height contribution so the trigger overlaps the row's natural
// line height instead of growing it. The comfortable tap target is `hitSlop`,
// never padding.
padding: theme.spacing[1],
width: FILE_ACTIONS_MENU_WIDTH,
marginVertical: -theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
triggerActive: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -1,5 +1,4 @@
import type { ImageAttachment } from "@/composer/types";
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
export interface DroppedFileItem {
kind: "web-file";
@@ -19,5 +18,4 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}
@@ -11,10 +11,6 @@ import {
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
import {
parseWorkspaceFileDragPayload,
WORKSPACE_FILE_DRAG_MIME,
} from "@/attachments/workspace-file-drag";
type DesktopDragDropPayload =
| { type: "enter"; paths: string[] }
@@ -189,11 +185,7 @@ export function useDropListeners({
if (disabledRef.current) return;
dragCounter.current++;
if (suppressed.value || !hasSink.value) return;
const types = new Set(e.dataTransfer?.types ?? []);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
if (types.has("Files") || acceptsWorkspaceFile) {
if (e.dataTransfer?.types.includes("Files")) {
isDragging.value = true;
}
}
@@ -205,11 +197,7 @@ export function useDropListeners({
if (!e.dataTransfer) return;
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
const types = new Set(e.dataTransfer.types);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
}
@@ -237,14 +225,6 @@ export function useDropListeners({
const sink = getSink();
if (!sink) return;
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
if (payload) {
sink.onWorkspaceFile(payload);
}
}
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",
+155 -110
View File
@@ -15,26 +15,35 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
ChevronDown,
Copy,
Download,
Eye,
EyeOff,
MoreVertical,
RotateCw,
} from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useDownloadStore } from "@/stores/download-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
@@ -53,8 +62,6 @@ function formatFileSize({ size }: { size: number }): string {
}
interface TreeRowItemProps {
serverId: string;
workspaceId?: string | null;
entry: ExplorerEntry;
depth: number;
isExpanded: boolean;
@@ -63,8 +70,21 @@ interface TreeRowItemProps {
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
testID?: string;
}
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function menuButtonStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.menuButton,
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
];
}
function sortTriggerStyle({
@@ -83,8 +103,6 @@ function treeRowKeyExtractor(row: TreeRow) {
}
function TreeRowItem({
serverId,
workspaceId,
entry,
depth,
isExpanded,
@@ -93,17 +111,10 @@ function TreeRowItem({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
testID,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory";
const dragSourceRef = useWorkspaceFileDragSource({
enabled: !isDirectory,
serverId,
workspaceId,
path: entry.path,
});
const handlePress = useCallback(() => {
onEntryPress(entry);
@@ -112,10 +123,10 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: treeRowPaddingLeft(depth) },
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected],
[depth, isSelected, theme.spacing],
);
const handleCopy = useCallback(() => {
@@ -126,38 +137,19 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
const handleAddToChat = useCallback(() => {
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
const copyLeading = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const downloadLeading = useMemo(
() => <Download size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
return (
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
<Pressable onPress={handlePress} style={pressableStyle}>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
@@ -171,15 +163,40 @@ function TreeRowItem({
{entry.name}
</Text>
</View>
<FileActionsMenu
fileKind={entry.kind}
onCopyPath={handleCopy}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
header={metaHeader}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
<DropdownMenu>
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
{t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
{t("workspace.fileExplorer.context.download")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</Pressable>
);
}
@@ -189,7 +206,6 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
interface TreeRow {
@@ -202,10 +218,14 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
@@ -215,6 +235,10 @@ export function FileExplorerPane({
}),
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
@@ -222,16 +246,12 @@ export function FileExplorerPane({
: undefined,
);
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const downloadFile = useFileDownload({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -326,14 +346,18 @@ export function FileExplorerPane({
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback(
(entry: ExplorerEntry) => {
if (entry.kind !== "file") {
return;
}
downloadFile({ fileName: entry.name, path: entry.path });
},
[downloadFile],
(entry: ExplorerEntry) =>
downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}),
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
const handleSortCycle = useCallback(() => {
@@ -395,8 +419,6 @@ export function FileExplorerPane({
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<TreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
info={info}
expandedPaths={expandedPaths}
selectedEntryPath={selectedEntryPath}
@@ -404,7 +426,6 @@ export function FileExplorerPane({
onEntryPress={handleEntryPress}
onCopyPath={handleCopyPath}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
/>
),
[
@@ -414,9 +435,6 @@ export function FileExplorerPane({
handleDownloadEntry,
isDirectoryLoading,
selectedEntryPath,
onAddToChat,
serverId,
workspaceId,
],
);
@@ -558,14 +576,8 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
testID="files-sort-trigger"
>
<Text style={styles.sortTriggerText} testID="files-sort-label">
{currentSortLabel}
</Text>
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
@@ -576,7 +588,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -595,7 +606,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
testID="files-refresh"
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
@@ -768,6 +778,39 @@ function resolveTreeRows({
});
}
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
type StartDownloadParams = Parameters<StartDownloadFn>[0];
function downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}: {
entry: ExplorerEntry;
workspaceScopeId: string | undefined;
serverId: string;
daemonProfile: StartDownloadParams["daemonProfile"];
startDownload: StartDownloadFn;
requestFileDownloadToken: (
targetPath: string,
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
}): void {
if (!workspaceScopeId || entry.kind !== "file") {
return;
}
startDownload({
serverId,
scopeId: workspaceScopeId,
fileName: entry.name,
path: entry.path,
daemonProfile,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -807,8 +850,6 @@ function toggleDirectory({
}
function TreeRowDispatcher({
serverId,
workspaceId,
info,
expandedPaths,
selectedEntryPath,
@@ -816,10 +857,7 @@ function TreeRowDispatcher({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<TreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
@@ -827,7 +865,6 @@ function TreeRowDispatcher({
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void | Promise<void>;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
}) {
const entry = info.item.entry;
const depth = info.item.depth;
@@ -838,8 +875,6 @@ function TreeRowDispatcher({
return (
<TreeRowItem
serverId={serverId}
workspaceId={workspaceId}
entry={entry}
depth={depth}
isExpanded={isExpanded}
@@ -848,8 +883,6 @@ function TreeRowDispatcher({
onEntryPress={onEntryPress}
onCopyPath={onCopyPath}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
testID={`file-explorer-row-${info.index}`}
/>
);
}
@@ -1028,6 +1061,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
@@ -1077,8 +1111,9 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: theme.spacing[3],
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1098,6 +1133,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
menuButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
menuButtonActive: {
backgroundColor: theme.colors.surface2,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
@@ -76,7 +76,7 @@ import {
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
@@ -11,7 +11,6 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
+1 -1
View File
@@ -338,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.setLightboxMetadata(input.attachment.metadata);
return;
}
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
if (input.attachment.kind === "file") {
return;
}
if (isWorkspaceAttachment(input.attachment)) {
@@ -9,7 +9,6 @@ import {
buildForgeAttachmentFromSearchItem,
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -50,11 +49,6 @@ export function splitComposerAttachmentsForSubmit(
continue;
}
if (attachment.kind === "workspace_file") {
agentAttachments.push(workspaceFileAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
@@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
const { asyncStorage } = vi.hoisted(() => ({
asyncStorage: new Map<string, string>(),
@@ -160,11 +159,7 @@ describe("useAgentInputDraft live contract", () => {
configurable: true,
});
useDraftStore.setState({
drafts: {},
createModalDraft: null,
attachmentFocusRequestByDraftKey: {},
});
useDraftStore.setState({ drafts: {}, createModalDraft: null });
});
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
@@ -429,39 +424,6 @@ describe("useAgentInputDraft live contract", () => {
});
});
it("attaches to an unmounted legacy draft without losing its input", async () => {
const image: AttachmentMetadata = {
id: "legacy-image",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/legacy-image",
createdAt: 10,
};
useDraftStore.setState({
drafts: {
"draft:legacy-workspace-file": {
input: { text: "legacy text", images: [image] },
lifecycle: "active",
updatedAt: Date.now(),
version: 1,
} as unknown as DraftRecordForTest,
},
});
await useDraftStore.getState().attachWorkspaceFile({
draftKey: "draft:legacy-workspace-file",
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
});
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
text: "legacy text",
attachments: [
{ kind: "image", metadata: image },
createWorkspaceFileAttachment({ path: "src/app.ts" }),
],
});
});
it("clear resets text and attachments", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
+96 -51
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
@@ -9,6 +9,7 @@ import {
} from "@/hooks/use-agent-form-state";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
@@ -21,7 +22,6 @@ import {
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
import { toDraftInputIfReady } from "@/stores/draft-store/state";
type AttachmentUpdater =
| UserComposerAttachment[]
@@ -57,7 +57,6 @@ export interface AgentInputDraft {
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
isHydrated: boolean;
attachmentFocusRequestId: number;
composerState: DraftComposerState | null;
}
@@ -78,66 +77,67 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
}),
[formState.selectedServerId, input.draftKey],
);
const draftRecord = useDraftStore((state) => state.drafts[draftKey]);
const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]);
const attachmentFocusRequestId = useDraftStore(
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const text = draft?.text ?? "";
const attachments = draft?.attachments ?? [];
const isHydrated = hydratedDraftKey === draftKey;
const [text, setText] = useState("");
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
const draftGenerationRef = useRef(0);
const hydratedGenerationRef = useRef(0);
const saveDraft = useCallback(
(
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
text: string;
attachments: UserComposerAttachment[];
},
) => {
const store = useDraftStore.getState();
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
const next = update(current);
if (!hasDraftContent(next)) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
return;
const setAttachments = useCallback((updater: AttachmentUpdater) => {
setAttachmentsState((previousAttachments) => {
if (typeof updater === "function") {
return updater(previousAttachments);
}
store.saveDraftInput({ draftKey, draft: next });
},
[draftKey],
);
const setText = useCallback(
(nextText: string) => {
saveDraft((current) => ({ ...current, text: nextText }));
},
[saveDraft],
);
const setAttachments = useCallback(
(updater: AttachmentUpdater) => {
saveDraft((current) => ({
...current,
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
}));
},
[saveDraft],
);
return updater;
});
}, []);
const clear = useCallback(
(lifecycle: "sent" | "abandoned") => {
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
const store = useDraftStore.getState();
store.clearDraftInput({ draftKey, lifecycle });
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = generation;
setText("");
setAttachmentsState([]);
setIsHydrated(true);
},
[draftKey],
);
useEffect(() => {
const store = useDraftStore.getState();
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = 0;
setText("");
setAttachmentsState([]);
setIsHydrated(false);
let cancelled = false;
void (async () => {
await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) {
setHydratedDraftKey(draftKey);
const draft = await store.hydrateDraftInput({
draftKey,
});
if (cancelled) {
return;
}
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
return;
}
if (draft) {
setText(draft.text);
setAttachmentsState(draft.attachments);
}
hydratedGenerationRef.current = generation;
setIsHydrated(true);
})();
return () => {
@@ -145,6 +145,52 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
};
}, [draftKey]);
useEffect(() => {
const currentGeneration = draftGenerationRef.current;
if (currentGeneration <= 0) {
return;
}
const store = useDraftStore.getState();
const isCurrentGeneration = store.isDraftGenerationCurrent({
draftKey,
generation: currentGeneration,
});
if (!isCurrentGeneration) {
return;
}
if (hydratedGenerationRef.current !== currentGeneration) {
return;
}
const existing = store.getDraftInput(draftKey);
const isSameDraft =
existing !== undefined &&
existing.text === text &&
areAttachmentsEqual({
left: existing.attachments,
right: attachments,
});
if (isSameDraft) {
return;
}
if (!hasDraftContent({ text, attachments })) {
if (existing) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
}
return;
}
store.saveDraftInput({
draftKey,
draft: {
text,
attachments,
},
});
}, [attachments, draftKey, text]);
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
useEffect(() => {
if (!composerOptions || !lockedWorkingDir) {
@@ -258,7 +304,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
};
}
@@ -50,7 +50,7 @@ import {
useIsCompactFormFactor,
} from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
@@ -751,7 +751,6 @@ export function WorkspaceDraftAgentTab({
<Composer
agentId={tabId}
serverId={serverId}
workspaceId={workspaceId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
@@ -766,7 +765,6 @@ export function WorkspaceDraftAgentTab({
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import { resolveFocusedChatTarget } from "./focused-chat-target";
function layoutWithTarget(
target: import("@/workspace-tabs/model").WorkspaceTab["target"],
): WorkspaceLayout {
return {
root: {
kind: "pane",
pane: {
id: "pane-1",
tabIds: ["focused-tab"],
focusedTabId: "focused-tab",
tabs: [{ tabId: "focused-tab", target, createdAt: 1 }],
},
},
focusedPaneId: "pane-1",
} as WorkspaceLayout;
}
describe("focused chat target", () => {
it("targets the focused agent draft", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "agent", agentId: "agent-1" }),
}),
).toEqual({ tabId: "focused-tab", draftKey: "agent:server-1:agent-1" });
});
it("targets the focused unsent draft", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "draft", draftId: "draft-1" }),
}),
).toEqual({ tabId: "focused-tab", draftKey: "draft:server-1:draft-1" });
});
it("does not guess when the focused tab is not a chat", () => {
expect(
resolveFocusedChatTarget({
serverId: "server-1",
layout: layoutWithTarget({ kind: "terminal", terminalId: "terminal-1" }),
}),
).toBeNull();
});
});
@@ -1,48 +0,0 @@
import { buildDraftStoreKey } from "@/stores/draft-keys";
import {
collectAllTabs,
findPaneById,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
export interface FocusedChatTarget {
tabId: string;
draftKey: string;
}
export function resolveFocusedChatTarget(input: {
serverId: string;
layout: WorkspaceLayout | undefined;
}): FocusedChatTarget | null {
if (!input.layout) {
return null;
}
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
const focusedTabId = pane?.focusedTabId;
if (!focusedTabId) {
return null;
}
const tab = collectAllTabs(input.layout.root).find(
(candidate) => candidate.tabId === focusedTabId,
);
if (!tab) {
return null;
}
if (tab.target.kind === "agent") {
return {
tabId: tab.tabId,
draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }),
};
}
if (tab.target.kind === "draft") {
return {
tabId: tab.tabId,
draftKey: buildDraftStoreKey({
serverId: input.serverId,
agentId: tab.tabId,
draftId: tab.target.draftId,
}),
};
}
return null;
}
+2 -85
View File
@@ -99,7 +99,6 @@ import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
WorkspaceFileComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
@@ -120,15 +119,6 @@ import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
getWorkspaceFileAttachmentKey,
getWorkspaceFileAttachmentSubtitle,
} from "@/attachments/workspace-file";
import {
resolveWorkspaceFileDrop,
type WorkspaceFileDragPayload,
} from "@/attachments/workspace-file-drag";
type QueuedMessage = QueuedComposerMessage;
@@ -409,18 +399,6 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
/>
);
}
if (attachment.kind === "workspace_file") {
return (
<WorkspaceFileAttachmentPill
key={`workspace-file:${getWorkspaceFileAttachmentKey(attachment)}`}
attachment={attachment}
index={index}
disabled={disabled}
onRemove={onRemove}
removeLabel={labels.removeFile}
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
@@ -733,43 +711,6 @@ function FileAttachmentPill({
);
}
interface WorkspaceFileAttachmentPillProps {
attachment: WorkspaceFileComposerAttachment;
index: number;
disabled: boolean;
onRemove: (index: number) => void;
removeLabel: string;
}
function WorkspaceFileAttachmentPill({
attachment,
index,
disabled,
onRemove,
removeLabel,
}: WorkspaceFileAttachmentPillProps) {
const handleRemove = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
const fileName = attachment.path.split("/").pop() ?? attachment.path;
return (
<AttachmentPill
testID="composer-workspace-file-attachment-pill"
onOpen={noopCallback}
onRemove={handleRemove}
openAccessibilityLabel={fileName}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<AttachmentLabel
icon={filePillIcon}
title={fileName}
subtitle={getWorkspaceFileAttachmentSubtitle(attachment)}
/>
</AttachmentPill>
);
}
interface GithubPickerOptionProps {
label: string;
testID: string;
@@ -814,7 +755,6 @@ function GithubPickerOption({
interface ComposerProps {
agentId: string;
serverId: string;
workspaceId?: string | null;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
@@ -846,8 +786,6 @@ interface ComposerProps {
clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */
autoFocus?: boolean;
/** Changing this value requests focus again while autoFocus remains true. */
autoFocusKey?: string;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
@@ -1039,7 +977,6 @@ function ComposerVoiceModeButton({
export function Composer({
agentId,
serverId,
workspaceId,
isPaneFocused,
onSubmitMessage,
onClientSlashCommand,
@@ -1063,7 +1000,6 @@ export function Composer({
cwd,
clearDraft,
autoFocus = false,
autoFocusKey,
onFocusInput,
commandDraftConfig,
onMessageSent,
@@ -1259,21 +1195,6 @@ export function Composer({
});
}, []);
const handleWorkspaceFileDropped = useCallback(
(payload: WorkspaceFileDragPayload) => {
if (!workspaceId) {
return;
}
const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId });
if (!attachment) {
return;
}
setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment));
focusInput();
},
[focusInput, serverId, setSelectedAttachments, workspaceId],
);
useEffect(() => {
onFocusInput?.(focusInput);
}, [focusInput, onFocusInput]);
@@ -2048,11 +1969,7 @@ export function Composer({
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
useFileDrop(
{
onFiles: addImages,
onGenericFiles: handleGenericFilesDropped,
onWorkspaceFile: handleWorkspaceFileDropped,
},
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
{ disabled: isSubmitBusy },
);
@@ -2113,7 +2030,7 @@ export function Composer({
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
autoFocus={messageInputAutoFocus}
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading}
isPaneFocused={isPaneFocused}
leftContent={leftContent}
+9 -43
View File
@@ -29,7 +29,6 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import {
buildAgentAttentionNotificationPayload,
type AgentAttentionReason,
type AgentAttentionNotificationPayload,
type NotificationPermissionRequest,
} from "@getpaseo/protocol/agent-attention-notification";
@@ -155,35 +154,6 @@ const getLatestPermissionRequest = (
return null;
};
interface AgentAttentionNotificationInput {
notification?: AgentAttentionNotificationPayload;
reason: AgentAttentionReason;
serverId: string;
workspaceId: string | undefined;
agentId: string;
assistantMessage: string | null;
permissionRequest: NotificationPermissionRequest | null;
}
function resolveAgentAttentionNotification(
input: AgentAttentionNotificationInput,
): AgentAttentionNotificationPayload | null {
if (input.notification) {
return input.notification.data.workspaceId ? input.notification : null;
}
if (!input.workspaceId) {
return null;
}
return buildAgentAttentionNotificationPayload({
reason: input.reason,
serverId: input.serverId,
workspaceId: input.workspaceId,
agentId: input.agentId,
assistantMessage: input.reason === "finished" ? input.assistantMessage : null,
permissionRequest: input.reason === "permission" ? input.permissionRequest : null,
});
}
type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
@@ -530,20 +500,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const assistantMessage =
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
const notification = resolveAgentAttentionNotification({
notification: params.notification,
reason: params.reason,
serverId,
workspaceId,
agentId: params.agentId,
assistantMessage,
permissionRequest,
});
if (!notification) {
return;
}
const notification =
params.notification ??
buildAgentAttentionNotificationPayload({
reason: params.reason,
serverId,
agentId: params.agentId,
assistantMessage: params.reason === "finished" ? assistantMessage : null,
permissionRequest: params.reason === "permission" ? permissionRequest : null,
});
void sendOsNotification({
title: notification.title,
@@ -150,10 +150,10 @@ const CATALOG_DATA = [
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.4.22",
version: "0.4.21",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.21", "--acp"],
},
{
id: "factory-droid",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.9.21",
version: "0.9.20",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.9.21", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.20", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -302,10 +302,10 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.1.3",
version: "1.1.2",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.2", "--acp"],
},
{
id: "qwen-code",
@@ -1,69 +0,0 @@
import { useEffect } from "react";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { getDesktopHost } from "@/desktop/host";
import { useStableEvent } from "@/hooks/use-stable-event";
import { navigateToAgent } from "@/utils/navigate-to-agent";
interface OpenAgentEventPayload {
serverId?: unknown;
agentId?: unknown;
}
export function AgentNavigationListener() {
const openAgent = useStableEvent((payload: OpenAgentEventPayload | null) => {
const serverId = typeof payload?.serverId === "string" ? payload.serverId.trim() : "";
const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : "";
if (!serverId || !agentId) {
return;
}
navigateToAgent({ serverId, agentId });
});
useEffect(() => {
const host = getDesktopHost();
const ready = host?.agentNavigation?.ready;
if (typeof host?.events?.on !== "function" || typeof ready !== "function") {
return;
}
let disposed = false;
let unlisten: (() => void) | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const connect = async () => {
let dispose: (() => void) | null = null;
try {
dispose = await listenToDesktopEvent<OpenAgentEventPayload>("open-agent", openAgent);
if (disposed) {
dispose();
return;
}
unlisten = dispose;
const pending = await ready();
if (!disposed && pending) {
openAgent(pending);
}
} catch {
dispose?.();
if (unlisten === dispose) {
unlisten = null;
}
if (!disposed) {
retryTimer = setTimeout(() => void connect(), 1_000);
}
}
};
void connect();
return () => {
disposed = true;
if (retryTimer) {
clearTimeout(retryTimer);
}
unlisten?.();
};
}, [openAgent]);
return null;
}
-5
View File
@@ -123,10 +123,6 @@ export interface DesktopEventsBridge {
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
}
export interface DesktopAgentNavigationBridge {
ready?: () => Promise<{ serverId: string; agentId: string } | null>;
}
export type DesktopBrowserShortcutEvent =
| { browserId?: string; action: "focus-url" }
| { browserId: string; action: "new-tab" };
@@ -173,7 +169,6 @@ export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
getPendingOpenProject?: () => Promise<string | null>;
agentNavigation?: DesktopAgentNavigationBridge;
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;
+4 -19
View File
@@ -8,13 +8,7 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DiffStat } from "@/components/diff-stat";
import { FILE_ACTIONS_MENU_WIDTH } from "@/components/file-actions-menu";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import { type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -86,12 +80,7 @@ export function DiffFolderRow({
</Text>
</View>
<View style={styles.right}>
<DiffStat
additions={additions}
deletions={deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
<View style={styles.actionSlot} />
<DiffStat additions={additions} deletions={deletions} />
</View>
</Pressable>
</View>
@@ -105,8 +94,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
folderRow: {
flexDirection: "row",
alignItems: "center",
paddingRight: theme.spacing[3],
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
gap: theme.spacing[1],
minWidth: 0,
},
@@ -124,10 +113,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: theme.spacing[1],
},
actionSlot: {
width: FILE_ACTIONS_MENU_WIDTH,
},
folderName: {
fontSize: theme.fontSize.sm,
File diff suppressed because it is too large Load Diff
+4 -13
View File
@@ -12,7 +12,6 @@ interface UseCheckoutDiffQueryOptions {
baseRef?: string;
ignoreWhitespace?: boolean;
enabled?: boolean;
queryScope?: string;
}
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
@@ -45,7 +44,6 @@ export function useCheckoutDiffQuery({
baseRef,
ignoreWhitespace,
enabled = true,
queryScope,
}: UseCheckoutDiffQueryOptions) {
const isConnected = useHostRuntimeIsConnected(serverId);
const normalizedCompare = useMemo(
@@ -55,17 +53,10 @@ export function useCheckoutDiffQuery({
const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef;
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
const queryKey = useMemo(() => {
const comparisonKey = checkoutDiffQueryKey(
serverId,
cwd,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
);
const normalizedScope = queryScope?.trim();
return normalizedScope ? [...comparisonKey, "scope", normalizedScope] : comparisonKey;
}, [serverId, cwd, compareMode, compareBaseRef, compareIgnoreWhitespace, queryScope]);
const queryKey = useMemo(
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
);
const subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
const routeEnabled = Boolean(enabled && isConnected && cwd);
-169
View File
@@ -1,169 +0,0 @@
import { useCallback, useEffect, useMemo } from "react";
import {
buildWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import {
buildReviewDraftKey,
buildReviewDraftScopeKey,
useInlineReviewController,
useResolvedDiffMode,
useReviewAttachmentSnapshot,
useSetDiffModeOverride,
} from "@/review";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
interface UseWorkingDiffOptions {
serverId: string;
workspaceId?: string;
cwd: string;
ignoreWhitespace: boolean;
enabled: boolean;
queryScope?: string;
}
export function useWorkingDiff({
serverId,
workspaceId,
cwd,
ignoreWhitespace,
enabled,
queryScope,
}: UseWorkingDiffOptions) {
const {
status,
isLoading: isStatusLoading,
isError: isStatusError,
error: statusError,
} = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
const statusErrorMessage =
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
const reviewDraftScopeKey = useMemo(
() =>
buildReviewDraftScopeKey({
serverId,
workspaceId,
cwd,
baseRef,
ignoreWhitespace,
}),
[baseRef, cwd, ignoreWhitespace, serverId, workspaceId],
);
const diffMode = useResolvedDiffMode({
scopeKey: reviewDraftScopeKey,
hasUncommittedChanges,
});
const setDiffModeOverride = useSetDiffModeOverride();
const selectDiffMode = useCallback(
(mode: "uncommitted" | "base") => {
setDiffModeOverride({
scopeKey: reviewDraftScopeKey,
override: { serverId, cwd, mode, isDirtyAtSelection: hasUncommittedChanges },
});
},
[cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride],
);
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
const {
files,
payloadError: diffPayloadError,
isLoading: isDiffLoading,
} = useCheckoutDiffQuery({
serverId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace,
enabled: enabled && isGit,
queryScope,
});
const reviewDraftKey = useMemo(
() =>
buildReviewDraftKey({
serverId,
workspaceId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace,
}),
[baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId],
);
const reviewActions = useInlineReviewController({ reviewDraftKey });
const reviewAttachment = useReviewAttachmentSnapshot({
key: reviewDraftKey,
diffFiles: files,
cwd,
mode: diffMode,
baseRef,
});
return {
status,
isStatusLoading,
isGit,
notGit,
statusErrorMessage,
baseRef,
currentBranchName,
diffMode,
selectUncommitted,
selectBase,
files,
diffPayloadError,
isDiffLoading,
reviewActions,
reviewAttachment,
};
}
export function usePublishWorkingDiffAttachment({
serverId,
workspaceId,
cwd,
attachment,
enabled,
}: {
serverId: string;
workspaceId?: string;
cwd: string;
attachment: ReturnType<typeof useWorkingDiff>["reviewAttachment"];
enabled: boolean;
}) {
const scopeKey = useMemo(
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
[cwd, serverId, workspaceId],
);
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.setWorkspaceAttachments,
);
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
(state) => state.clearWorkspaceAttachments,
);
useEffect(() => {
if (!enabled) {
return;
}
const attachments = attachment ? [attachment] : [];
setWorkspaceAttachments({ scopeKey, attachments });
return () => {
const current = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey];
if (current === attachments) {
clearWorkspaceAttachments({ scopeKey });
}
};
}, [attachment, clearWorkspaceAttachments, enabled, scopeKey, setWorkspaceAttachments]);
}
@@ -1,56 +0,0 @@
import { useCallback, useMemo } from "react";
import { useHosts } from "@/runtime/host-runtime";
import { useDownloadStore } from "@/stores/download-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
interface UseFileDownloadParams {
serverId: string;
workspaceId?: string | null;
workspaceRoot: string;
}
/**
* Returns a stable callback that downloads a single workspace file by its
* workspace-relative path. Shared by the file explorer tree and the git diff
* pane so both surfaces download through the same host token + download-store
* pipeline instead of duplicating the plumbing.
*/
export function useFileDownload({
serverId,
workspaceId,
workspaceRoot,
}: UseFileDownloadParams): (input: { fileName: string; path: string }) => void {
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const { requestFileDownloadToken } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const startDownload = useDownloadStore((state) => state.startDownload);
return useCallback(
({ fileName, path }) => {
if (!workspaceScopeId) {
return;
}
void startDownload({
serverId,
scopeId: workspaceScopeId,
fileName,
path,
daemonProfile,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
},
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
}
+2 -11
View File
@@ -366,13 +366,6 @@ export const ar: TranslationResources = {
copyBranchName: "نسخ اسم الفرع",
copied: "تم النسخ",
},
fileActions: {
openFile: "افتح الملف",
copyPath: "نسخ المسار",
download: "تحميل",
addToChat: "إضافة إلى الدردشة…",
moreActions: "المزيد من الإجراءات",
},
fileExplorer: {
sort: {
name: "اسم",
@@ -382,6 +375,8 @@ export const ar: TranslationResources = {
context: {
size: "مقاس",
modified: "معدل",
copyPath: "نسخ المسار",
download: "تحميل",
},
actions: {
back: "خلف",
@@ -758,8 +753,6 @@ export const ar: TranslationResources = {
},
},
diff: {
openChangesTab: "فتح علامة تبويب التغييرات",
closeChangesTab: "إغلاق علامة تبويب التغييرات",
binaryFile: "ملف ثنائي",
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
unified: "الفرق الموحدة",
@@ -1496,8 +1489,6 @@ export const ar: TranslationResources = {
changesLabel: "التغييرات",
changesSubtitle: "فروقات شجرة العمل",
commitSubtitle: "فروقات الالتزام",
uncommittedSubtitle: "تغييرات غير ملتزم بها",
baseSubtitle: "مقارنة مع {{baseRef}}",
directoryMissing: "لم يتم العثور على دليل Workspace.",
empty: "لا توجد تغييرات",
loadError: "فشل تحميل الفروقات",
+2 -11
View File
@@ -365,13 +365,6 @@ export const en = {
copyBranchName: "Copy branch name",
copied: "Copied",
},
fileActions: {
openFile: "Open file",
copyPath: "Copy path",
download: "Download",
addToChat: "Add to chat…",
moreActions: "More actions",
},
fileExplorer: {
sort: {
name: "Name",
@@ -381,6 +374,8 @@ export const en = {
context: {
size: "Size",
modified: "Modified",
copyPath: "Copy path",
download: "Download",
},
actions: {
back: "Back",
@@ -768,8 +763,6 @@ export const en = {
},
},
diff: {
openChangesTab: "Open Changes tab",
closeChangesTab: "Close Changes tab",
binaryFile: "Binary file",
tooLarge: "Diff too large to display",
unified: "Unified diff",
@@ -1507,8 +1500,6 @@ export const en = {
changesLabel: "Changes",
changesSubtitle: "Working tree diff",
commitSubtitle: "Commit diff",
uncommittedSubtitle: "Uncommitted changes",
baseSubtitle: "Compared with {{baseRef}}",
directoryMissing: "Workspace directory not found.",
empty: "No changes",
loadError: "Failed to load diff",
+2 -11
View File
@@ -370,13 +370,6 @@ export const es: TranslationResources = {
copyBranchName: "Copiar nombre de rama",
copied: "Copiado",
},
fileActions: {
openFile: "Abrir archivo",
copyPath: "Copiar ruta",
download: "Descargar",
addToChat: "Añadir al chat…",
moreActions: "Más acciones",
},
fileExplorer: {
sort: {
name: "Nombre",
@@ -386,6 +379,8 @@ export const es: TranslationResources = {
context: {
size: "Tamaño",
modified: "Modificado",
copyPath: "Copiar ruta",
download: "Descargar",
},
actions: {
back: "Atrás",
@@ -789,8 +784,6 @@ export const es: TranslationResources = {
},
},
diff: {
openChangesTab: "Abrir la pestaña Cambios",
closeChangesTab: "Cerrar la pestaña Cambios",
binaryFile: "archivo binario",
tooLarge: "La diferencia es demasiado grande para mostrarse",
unified: "Diferencia unificada",
@@ -1539,8 +1532,6 @@ export const es: TranslationResources = {
changesLabel: "Cambios",
changesSubtitle: "Diferencias del árbol de trabajo",
commitSubtitle: "Diferencias del commit",
uncommittedSubtitle: "Cambios sin confirmar",
baseSubtitle: "Comparado con {{baseRef}}",
directoryMissing: "No se encontró el directorio de Workspace.",
empty: "Sin cambios",
loadError: "No se pudieron cargar las diferencias",
+2 -11
View File
@@ -370,13 +370,6 @@ export const fr: TranslationResources = {
copyBranchName: "Copier le nom de la branche",
copied: "Copié",
},
fileActions: {
openFile: "Ouvrir le fichier",
copyPath: "Copier le chemin",
download: "Télécharger",
addToChat: "Ajouter au chat…",
moreActions: "Plus de propositions",
},
fileExplorer: {
sort: {
name: "Nom",
@@ -386,6 +379,8 @@ export const fr: TranslationResources = {
context: {
size: "Taille",
modified: "Modifié",
copyPath: "Copier le chemin",
download: "Télécharger",
},
actions: {
back: "Dos",
@@ -788,8 +783,6 @@ export const fr: TranslationResources = {
},
},
diff: {
openChangesTab: "Ouvrir l'onglet Modifications",
closeChangesTab: "Fermer l'onglet Modifications",
binaryFile: "Fichier binaire",
tooLarge: "Diff trop grand pour être affiché",
unified: "Différentiel unifié",
@@ -1542,8 +1535,6 @@ export const fr: TranslationResources = {
changesLabel: "Modifications",
changesSubtitle: "Différences de l'arbre de travail",
commitSubtitle: "Différences du commit",
uncommittedSubtitle: "Modifications non validées",
baseSubtitle: "Comparé à {{baseRef}}",
directoryMissing: "Répertoire Workspace introuvable.",
empty: "Aucune modification",
loadError: "Échec du chargement des différences",
+2 -11
View File
@@ -370,13 +370,6 @@ export const ja: TranslationResources = {
copyBranchName: "ブランチ名をコピー",
copied: "コピーしました",
},
fileActions: {
openFile: "ファイルを開く",
copyPath: "パスをコピー",
download: "ダウンロード",
addToChat: "チャットに追加…",
moreActions: "その他のアクション",
},
fileExplorer: {
sort: {
name: "名前",
@@ -386,6 +379,8 @@ export const ja: TranslationResources = {
context: {
size: "サイズ",
modified: "更新日時",
copyPath: "パスをコピー",
download: "ダウンロード",
},
actions: {
back: "戻る",
@@ -769,8 +764,6 @@ export const ja: TranslationResources = {
},
},
diff: {
openChangesTab: "変更タブを開く",
closeChangesTab: "変更タブを閉じる",
binaryFile: "バイナリファイル",
tooLarge: "差分が大きすぎて表示できません",
unified: "ユニファイド差分",
@@ -1512,8 +1505,6 @@ export const ja: TranslationResources = {
changesLabel: "変更",
changesSubtitle: "作業ツリーの差分",
commitSubtitle: "コミット差分",
uncommittedSubtitle: "未コミットの変更",
baseSubtitle: "{{baseRef}} との比較",
directoryMissing: "ワークスペースディレクトリが見つかりません。",
empty: "変更はありません",
loadError: "差分の読み込みに失敗しました",
+2 -11
View File
@@ -370,13 +370,6 @@ export const ptBR: TranslationResources = {
copyBranchName: "Copiar nome da branch",
copied: "Copiado",
},
fileActions: {
openFile: "Abrir arquivo",
copyPath: "Copiar caminho",
download: "Baixar",
addToChat: "Adicionar ao chat…",
moreActions: "Mais ações",
},
fileExplorer: {
sort: {
name: "Nome",
@@ -386,6 +379,8 @@ export const ptBR: TranslationResources = {
context: {
size: "Tamanho",
modified: "Modificado",
copyPath: "Copiar caminho",
download: "Baixar",
},
actions: {
back: "Voltar",
@@ -780,8 +775,6 @@ export const ptBR: TranslationResources = {
},
},
diff: {
openChangesTab: "Abrir a aba Alterações",
closeChangesTab: "Fechar a aba Alterações",
binaryFile: "Arquivo binário",
tooLarge: "Diff grande demais para exibir",
unified: "Diff unificado",
@@ -1525,8 +1518,6 @@ export const ptBR: TranslationResources = {
changesLabel: "Alterações",
changesSubtitle: "Diff da árvore de trabalho",
commitSubtitle: "Diff do commit",
uncommittedSubtitle: "Alterações não commitadas",
baseSubtitle: "Comparado com {{baseRef}}",
directoryMissing: "Diretório do workspace não encontrado.",
empty: "Nenhuma alteração",
loadError: "Falha ao carregar diff",
+2 -11
View File
@@ -369,13 +369,6 @@ export const ru: TranslationResources = {
copyBranchName: "Копировать имя ветки",
copied: "Скопировано",
},
fileActions: {
openFile: "Открыть файл",
copyPath: "Копировать путь",
download: "Скачать",
addToChat: "Добавить в чат…",
moreActions: "Дополнительные действия",
},
fileExplorer: {
sort: {
name: "Имя",
@@ -385,6 +378,8 @@ export const ru: TranslationResources = {
context: {
size: "Размер",
modified: "Модифицированный",
copyPath: "Копировать путь",
download: "Скачать",
},
actions: {
back: "Назад",
@@ -780,8 +775,6 @@ export const ru: TranslationResources = {
},
},
diff: {
openChangesTab: "Открыть вкладку «Изменения»",
closeChangesTab: "Закрыть вкладку «Изменения»",
binaryFile: "Бинарный файл",
tooLarge: "Разница слишком велика для отображения",
unified: "Единый дифференциал",
@@ -1530,8 +1523,6 @@ export const ru: TranslationResources = {
changesLabel: "Изменения",
changesSubtitle: "Различия рабочего дерева",
commitSubtitle: "Различия коммита",
uncommittedSubtitle: "Незафиксированные изменения",
baseSubtitle: "Сравнение с {{baseRef}}",
directoryMissing: "Каталог Workspace не найден.",
empty: "Нет изменений",
loadError: "Не удалось загрузить различия",
+2 -11
View File
@@ -366,13 +366,6 @@ export const zhCN: TranslationResources = {
copyBranchName: "复制分支名称",
copied: "已复制",
},
fileActions: {
openFile: "打开文件",
copyPath: "复制路径",
download: "下载",
addToChat: "添加到聊天…",
moreActions: "更多操作",
},
fileExplorer: {
sort: {
name: "名称",
@@ -382,6 +375,8 @@ export const zhCN: TranslationResources = {
context: {
size: "大小",
modified: "修改时间",
copyPath: "复制路径",
download: "下载",
},
actions: {
back: "返回",
@@ -750,8 +745,6 @@ export const zhCN: TranslationResources = {
},
},
diff: {
openChangesTab: "打开“更改”标签页",
closeChangesTab: "关闭“更改”标签页",
binaryFile: "二进制文件",
tooLarge: "Diff 过大,无法显示",
unified: "Unified diff",
@@ -1477,8 +1470,6 @@ export const zhCN: TranslationResources = {
changesLabel: "更改",
changesSubtitle: "工作区差异",
commitSubtitle: "提交差异",
uncommittedSubtitle: "未提交的更改",
baseSubtitle: "与 {{baseRef}} 比较",
directoryMissing: "未找到 workspace 目录。",
empty: "没有更改",
loadError: "加载差异失败",
@@ -1,147 +0,0 @@
import { Text, View } from "react-native";
import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentRouteResolution } from "@/navigation/agent-route-resolution";
import { formatConnectionStatus } from "@/utils/daemons";
import type { Theme } from "@/styles/theme";
type VisibleAgentRouteResolution = Extract<
AgentRouteResolution,
{ kind: "waitingForHost" | "fetchingAgent" | "lookupError" }
>;
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export function AgentRouteResolutionView({
resolution,
hostName,
lastHostError,
onRetry,
onManageHost,
onBack,
}: {
resolution: VisibleAgentRouteResolution;
hostName: string;
lastHostError: string | null;
onRetry: () => void;
onManageHost: () => void;
onBack: () => void;
}) {
const { t } = useTranslation();
if (resolution.kind === "fetchingAgent") {
return (
<View style={styles.emptyState} testID="agent-route-fetching">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<View style={styles.textStack}>
<Text style={styles.title}>
{t("agentPanel.unavailable.preparingSession", { serverLabel: hostName })}
</Text>
<Text style={styles.description}>{t("agentPanel.unavailable.showSoon")}</Text>
</View>
</View>
);
}
if (resolution.kind === "lookupError") {
return (
<View style={styles.emptyState} testID="agent-route-lookup-error">
<View style={styles.textStack}>
<Text style={styles.title}>{t("agentPanel.states.failedToLoad")}</Text>
<Text style={styles.error}>{resolution.error}</Text>
</View>
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={ArrowLeftToLine} onPress={onBack}>
{t("common.actions.back")}
</Button>
</View>
</View>
);
}
const isConnecting =
resolution.connectionStatus === "connecting" || resolution.connectionStatus === "idle";
let title = t("workspace.route.cannotReachHost", { hostName });
if (isConnecting) {
title = t("agentPanel.unavailable.connecting", { serverLabel: hostName });
} else if (resolution.connectionStatus === "offline") {
title = t("workspace.route.hostOffline", { hostName });
}
return (
<View style={styles.emptyState} testID="agent-route-waiting-for-host">
{isConnecting ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
<View style={styles.textStack}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>
{isConnecting
? t("agentPanel.unavailable.showWhenOnline")
: t("workspace.route.hostStatus", {
status: formatConnectionStatus(resolution.connectionStatus),
})}
</Text>
{lastHostError ? <Text style={styles.error}>{lastHostError}</Text> : null}
</View>
{!isConnecting ? (
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={Settings} onPress={onManageHost}>
{t("workspace.route.manageHost")}
</Button>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
},
textStack: {
alignItems: "center",
gap: theme.spacing[2],
maxWidth: 520,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
lineHeight: Math.round(theme.fontSize.sm * 1.4),
textAlign: "center",
},
actions: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
},
}));
@@ -1,85 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveAgentRoute } from "@/navigation/agent-route-resolution";
const VALID_ROUTE = {
serverId: "server-1",
agentId: "agent-1",
cachedWorkspaceId: null,
} as const;
describe("resolveAgentRoute", () => {
it("opens a cached workspace without waiting for its host", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
cachedWorkspaceId: "workspace-1",
connectionStatus: "offline",
lookup: { kind: "idle" },
}),
).toEqual({ kind: "resolved", workspaceId: "workspace-1" });
});
it.each(["idle", "connecting", "offline", "error"] as const)(
"waits for a %s target host instead of abandoning the agent",
(connectionStatus) => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus,
lookup: { kind: "idle" },
}),
).toEqual({ kind: "waitingForHost", connectionStatus });
},
);
it("fetches the agent after its target host connects", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus: "online",
lookup: { kind: "idle" },
}),
).toEqual({ kind: "fetchingAgent" });
});
it("opens the workspace returned by the target host", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus: "online",
lookup: { kind: "found", workspaceId: "workspace-2" },
}),
).toEqual({ kind: "resolved", workspaceId: "workspace-2" });
});
it("abandons the agent only after the target host says it is missing", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus: "online",
lookup: { kind: "found", workspaceId: null },
}),
).toEqual({ kind: "notFound" });
});
it("keeps lookup failures retryable", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
connectionStatus: "online",
lookup: { kind: "failed", error: "connection closed" },
}),
).toEqual({ kind: "lookupError", error: "connection closed" });
});
it("rejects incomplete route parameters", () => {
expect(
resolveAgentRoute({
...VALID_ROUTE,
agentId: "",
connectionStatus: "online",
lookup: { kind: "idle" },
}),
).toEqual({ kind: "invalid" });
});
});
@@ -1,53 +0,0 @@
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export type AgentRouteLookup =
| { kind: "idle" }
| { kind: "fetching" }
| { kind: "found"; workspaceId: string | null | undefined }
| { kind: "failed"; error: string };
export type AgentRouteResolution =
| { kind: "invalid" }
| { kind: "resolved"; workspaceId: string }
| {
kind: "waitingForHost";
connectionStatus: Exclude<HostRuntimeConnectionStatus, "online">;
}
| { kind: "fetchingAgent" }
| { kind: "notFound" }
| { kind: "lookupError"; error: string };
export function resolveAgentRoute(input: {
serverId: string;
agentId: string;
cachedWorkspaceId: string | null | undefined;
connectionStatus: HostRuntimeConnectionStatus;
lookup: AgentRouteLookup;
}): AgentRouteResolution {
if (!input.serverId || !input.agentId) {
return { kind: "invalid" };
}
const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId);
if (cachedWorkspaceId) {
return { kind: "resolved", workspaceId: cachedWorkspaceId };
}
if (input.connectionStatus !== "online") {
return { kind: "waitingForHost", connectionStatus: input.connectionStatus };
}
if (input.lookup.kind === "found") {
const fetchedWorkspaceId = normalizeWorkspaceOpaqueId(input.lookup.workspaceId);
return fetchedWorkspaceId
? { kind: "resolved", workspaceId: fetchedWorkspaceId }
: { kind: "notFound" };
}
if (input.lookup.kind === "failed") {
return { kind: "lookupError", error: input.lookup.error };
}
return { kind: "fetchingAgent" };
}
+4 -24
View File
@@ -73,7 +73,7 @@ import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
import { type Agent, useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import type { Theme } from "@/styles/theme";
import {
useHideFinishedProviderSubagents,
@@ -1160,16 +1160,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
});
// Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out
// when only toast state changes (which does not affect any draft field).
const {
text,
setText,
attachments,
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
} = rawAgentInputDraft;
const { text, setText, attachments, setAttachments, clear, isHydrated, composerState } =
rawAgentInputDraft;
const agentInputDraft = useMemo(
(): AgentInputDraft => ({
text,
@@ -1178,19 +1170,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
}),
[
text,
setText,
attachments,
setAttachments,
clear,
isHydrated,
attachmentFocusRequestId,
composerState,
],
[text, setText, attachments, setAttachments, clear, isHydrated, composerState],
);
const streamSection = (
<RenderProfile id={`AgentStreamSection:${agentId}`}>
@@ -1555,7 +1537,6 @@ function ActiveAgentComposer({
<Composer
agentId={agentId}
serverId={serverId}
workspaceId={workspaceId}
externalKeyboardShift
isPaneFocused={isPaneFocused}
value={agentInputDraft.text}
@@ -1567,7 +1548,6 @@ function ActiveAgentComposer({
cwd={cwd}
clearDraft={agentInputDraft.clear}
autoFocus={isPaneFocused}
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
isSubmitLoading={isSubmitLoading}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
@@ -0,0 +1,184 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { GitCommitHorizontal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import { SharedDiffView } from "@/git/diff-pane";
import { useCommitDiffFiles } from "@/git/use-diff-files";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
function CommitDiffPanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const { settings } = useAppSettings();
const { preferences, updatePreferences } = useChangesPreferences();
const isCompact = useIsCompactFormFactor();
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
serverId,
cwd: cwd ?? "",
sha: target.sha,
enabled: Boolean(cwd),
});
const canUseSplitLayout = isWeb && !isCompact;
const effectiveLayout = canUseSplitLayout ? preferences.layout : "unified";
const layoutOptions = useMemo(
() => [
{
value: "unified" as const,
label: t("workspace.git.diff.unified"),
testID: "commit-diff-layout-unified",
},
{
value: "split" as const,
label: t("workspace.git.diff.split"),
testID: "commit-diff-layout-split",
},
],
[t],
);
const handleLayoutChange = useCallback(
(layout: "unified" | "split") => {
void updatePreferences({ layout });
},
[updatePreferences],
);
const displayPreferences = useMemo(
() => ({
layout: effectiveLayout,
wrapLines: preferences.wrapLines,
codeFontSize: settings.codeFontSize,
monoFontFamily: settings.monoFontFamily,
}),
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
);
const commitMode = useMemo(() => ({ kind: "commit" as const }), []);
if (!cwd) {
return (
<View style={styles.centerState}>
<Text style={styles.mutedText}>{t("panels.diff.directoryMissing")}</Text>
</View>
);
}
if (capabilityMissing) {
return (
<View style={styles.centerState} testID="commit-diff-capability-missing">
<Text style={styles.mutedText}>{t("panels.diff.capabilityMissing")}</Text>
</View>
);
}
let bodyContent: ReactNode;
if (error) {
bodyContent = (
<View style={styles.centerState} testID="commit-diff-error">
<Text style={styles.errorText}>{t("panels.diff.loadError")}</Text>
</View>
);
} else if (isLoading && files.length === 0) {
bodyContent = (
<View style={styles.centerState} testID="commit-diff-loading">
<Text style={styles.mutedText}>{t("workspace.tabs.loading")}</Text>
</View>
);
} else if (files.length === 0) {
bodyContent = (
<View style={styles.centerState} testID="commit-diff-empty">
<Text style={styles.mutedText}>{t("panels.diff.empty")}</Text>
</View>
);
} else {
bodyContent = (
<SharedDiffView files={files} displayPreferences={displayPreferences} mode={commitMode} />
);
}
return (
<View style={styles.container} testID="commit-diff-panel">
{canUseSplitLayout ? (
<View style={styles.toolbar} testID="commit-diff-toolbar">
<SegmentedControl
options={layoutOptions}
value={preferences.layout}
onValueChange={handleLayoutChange}
size="sm"
testID="commit-diff-layout-control"
/>
</View>
) : null}
<View style={styles.body}>{bodyContent}</View>
</View>
);
}
function useCommitDiffPanelDescriptor(
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
): PanelDescriptor {
const { t } = useTranslation();
return {
label: target.sha.slice(0, 7),
subtitle: t("panels.diff.commitSubtitle"),
tooltip: target.sha,
titleState: "ready",
icon: ThemedGitCommitHorizontal,
statusBucket: null,
};
}
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
kind: "commit_diff",
component: CommitDiffPanel,
useDescriptor: useCommitDiffPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
},
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
body: {
flex: 1,
minHeight: 0,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[16],
},
mutedText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
errorText: {
fontSize: theme.fontSize.base,
color: theme.colors.destructive,
textAlign: "center",
},
}));
-409
View File
@@ -1,409 +0,0 @@
import { useCallback, useMemo, useState, type ComponentProps, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { FileDiff, GitCommitHorizontal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import {
DiffFilesToolbar,
DiffLayoutToggle,
DiffModeMenu,
DiffOptionsMenu,
resolveDiffLayout,
SharedDiffView,
} from "@/git/diff-pane";
import { useCommitDiffFiles } from "@/git/use-diff-files";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
const ThemedFileDiff = withUnistyles(FileDiff);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
function useDiffPanelPreferences() {
const { settings } = useAppSettings();
const { preferences, updatePreferences } = useChangesPreferences();
const isCompact = useIsCompactFormFactor();
const canUseSplitLayout = isWeb && !isCompact;
const effectiveLayout = resolveDiffLayout(preferences.layout, canUseSplitLayout);
const displayPreferences = useMemo(
() => ({
layout: effectiveLayout,
wrapLines: preferences.wrapLines,
codeFontSize: settings.codeFontSize,
monoFontFamily: settings.monoFontFamily,
}),
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
);
const toggleLayout = useCallback(() => {
void updatePreferences({ layout: preferences.layout === "unified" ? "split" : "unified" });
}, [preferences.layout, updatePreferences]);
const toggleWrapLines = useCallback(() => {
void updatePreferences({ wrapLines: !preferences.wrapLines });
}, [preferences.wrapLines, updatePreferences]);
const toggleHideWhitespace = useCallback(() => {
void updatePreferences({ hideWhitespace: !preferences.hideWhitespace });
}, [preferences.hideWhitespace, updatePreferences]);
return {
preferences,
isCompact,
canUseSplitLayout,
displayPreferences,
toggleLayout,
toggleWrapLines,
toggleHideWhitespace,
};
}
function PanelState({
message,
tone = "muted",
testID,
}: {
message: string;
tone?: "muted" | "error";
testID?: string;
}) {
return (
<View style={styles.centerState} testID={testID}>
<Text style={tone === "error" ? styles.errorText : styles.mutedText}>{message}</Text>
</View>
);
}
function WorkingDiffBody({
cwd,
isConnected,
workingDiff,
hideWhitespace,
displayPreferences,
mode,
}: {
cwd: string | null | undefined;
isConnected: boolean;
workingDiff: ReturnType<typeof useWorkingDiff>;
hideWhitespace: boolean;
displayPreferences: ReturnType<typeof useDiffPanelPreferences>["displayPreferences"];
mode: Extract<ComponentProps<typeof SharedDiffView>["mode"], { kind: "working_tab" }>;
}) {
const { t } = useTranslation();
if (!cwd) {
return <PanelState message={t("panels.diff.directoryMissing")} />;
}
if (!isConnected) {
return <PanelState message={t("workspace.terminal.hostDisconnected")} />;
}
if (workingDiff.isStatusLoading) {
return <PanelState message={t("workspace.git.diff.checkingRepository")} />;
}
if (workingDiff.statusErrorMessage) {
return (
<PanelState
message={workingDiff.statusErrorMessage}
tone="error"
testID="working-diff-error"
/>
);
}
if (workingDiff.notGit) {
return <PanelState message={t("workspace.git.diff.notRepository")} />;
}
if (workingDiff.diffPayloadError) {
return (
<PanelState message={t("panels.diff.loadError")} tone="error" testID="working-diff-error" />
);
}
if (workingDiff.isDiffLoading && workingDiff.files.length === 0) {
return <PanelState message={t("workspace.tabs.loading")} testID="working-diff-loading" />;
}
if (workingDiff.files.length === 0) {
return (
<PanelState
message={
hideWhitespace ? t("workspace.git.diff.emptyHiddenWhitespace") : t("panels.diff.empty")
}
testID="working-diff-empty"
/>
);
}
return (
<SharedDiffView files={workingDiff.files} displayPreferences={displayPreferences} mode={mode} />
);
}
function WorkingDiffPanel() {
const { t } = useTranslation();
const toast = useToast();
const { serverId, workspaceId, tabId, target } = usePaneContext();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isActive = useRetainedPanelActive();
const panelPreferences = useDiffPanelPreferences();
const [expandedPaths, setExpandedPaths] = useState<string[] | null>(null);
invariant(target.kind === "working_diff", "WorkingDiffPanel requires working_diff target");
const workingDiff = useWorkingDiff({
serverId,
workspaceId,
cwd: cwd ?? "",
ignoreWhitespace: panelPreferences.preferences.hideWhitespace,
enabled: Boolean(cwd) && isActive,
queryScope: `working-diff-tab:${tabId}`,
});
usePublishWorkingDiffAttachment({
serverId,
workspaceId,
cwd: cwd ?? "",
attachment: workingDiff.reviewAttachment,
enabled: Boolean(cwd) && isActive,
});
const refreshSupported = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
const isRefreshing =
useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd: cwd ?? "", actionId: "refresh" }),
) === "pending";
const refresh = useCallback(() => {
if (!cwd || isRefreshing) {
return;
}
void runRefresh({ serverId, cwd }).catch((error) => {
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
});
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
const expandedPathSet = useMemo(
() => (expandedPaths === null ? null : new Set(expandedPaths)),
[expandedPaths],
);
const allFilesExpanded =
workingDiff.files.length > 0 &&
(expandedPathSet === null || workingDiff.files.every((file) => expandedPathSet.has(file.path)));
const toggleExpandAll = useCallback(() => {
setExpandedPaths(allFilesExpanded ? [] : null);
}, [allFilesExpanded]);
const mode = useMemo(
() => ({
kind: "working_tab" as const,
expandedPaths,
reviewActions: workingDiff.reviewActions,
focusPath: target.focusPath,
focusRequestId: target.focusRequestId,
onExpandedPathsChange: setExpandedPaths,
}),
[expandedPaths, target.focusPath, target.focusRequestId, workingDiff.reviewActions],
);
const baseRefLabel = workingDiff.baseRef?.replace(/^refs\/(heads|remotes)\//, "") ?? "";
return (
<View style={styles.container} testID="working-diff-panel">
<View style={styles.toolbar}>
<DiffModeMenu
diffMode={workingDiff.diffMode}
committedDescription={baseRefLabel || undefined}
testIDPrefix="working-diff"
onSelectUncommitted={workingDiff.selectUncommitted}
onSelectBase={workingDiff.selectBase}
/>
<View style={styles.toolbarActions} testID="working-diff-toolbar">
{panelPreferences.canUseSplitLayout ? (
<DiffLayoutToggle
layout={panelPreferences.preferences.layout}
isMobile={panelPreferences.isCompact}
testID="working-diff-toggle-layout"
onToggle={panelPreferences.toggleLayout}
/>
) : null}
{workingDiff.files.length > 0 ? (
<DiffFilesToolbar
allFileDiffsExpanded={allFilesExpanded}
isMobile={panelPreferences.isCompact}
testID="working-diff-toggle-expand-all"
onToggleExpandAll={toggleExpandAll}
/>
) : null}
<DiffOptionsMenu
hideWhitespace={panelPreferences.preferences.hideWhitespace}
isMobile={panelPreferences.isCompact}
isRefreshing={isRefreshing}
refreshSupported={refreshSupported}
testIDPrefix="working-diff"
wrapLines={panelPreferences.preferences.wrapLines}
onRefresh={refresh}
onToggleHideWhitespace={panelPreferences.toggleHideWhitespace}
onToggleWrapLines={panelPreferences.toggleWrapLines}
/>
</View>
</View>
<View style={styles.body}>
<WorkingDiffBody
cwd={cwd}
isConnected={isConnected}
workingDiff={workingDiff}
hideWhitespace={panelPreferences.preferences.hideWhitespace}
displayPreferences={panelPreferences.displayPreferences}
mode={mode}
/>
</View>
</View>
);
}
function CommitDiffPanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const panelPreferences = useDiffPanelPreferences();
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
serverId,
cwd: cwd ?? "",
sha: target.sha,
enabled: Boolean(cwd),
});
const mode = useMemo(() => ({ kind: "commit" as const }), []);
let body: ReactNode;
if (!cwd) {
body = <PanelState message={t("panels.diff.directoryMissing")} />;
} else if (capabilityMissing) {
body = (
<PanelState
message={t("panels.diff.capabilityMissing")}
testID="commit-diff-capability-missing"
/>
);
} else if (error) {
body = (
<PanelState message={t("panels.diff.loadError")} tone="error" testID="commit-diff-error" />
);
} else if (isLoading && files.length === 0) {
body = <PanelState message={t("workspace.tabs.loading")} testID="commit-diff-loading" />;
} else if (files.length === 0) {
body = <PanelState message={t("panels.diff.empty")} testID="commit-diff-empty" />;
} else {
body = (
<SharedDiffView
files={files}
displayPreferences={panelPreferences.displayPreferences}
mode={mode}
/>
);
}
return (
<View style={styles.container} testID="commit-diff-panel">
{panelPreferences.canUseSplitLayout ? (
<View style={styles.toolbar}>
<View style={styles.toolbarActions} testID="commit-diff-toolbar">
<DiffLayoutToggle
layout={panelPreferences.preferences.layout}
isMobile={panelPreferences.isCompact}
testID="commit-diff-toggle-layout"
onToggle={panelPreferences.toggleLayout}
/>
</View>
</View>
) : null}
<View style={styles.body}>{body}</View>
</View>
);
}
function useWorkingDiffPanelDescriptor(): PanelDescriptor {
const { t } = useTranslation();
return {
label: t("panels.diff.changesLabel"),
subtitle: t("panels.diff.changesSubtitle"),
tooltip: t("panels.diff.changesSubtitle"),
titleState: "ready",
icon: ThemedFileDiff,
statusBucket: null,
};
}
function useCommitDiffPanelDescriptor(
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
): PanelDescriptor {
const { t } = useTranslation();
return {
label: target.sha.slice(0, 7),
subtitle: t("panels.diff.commitSubtitle"),
tooltip: target.sha,
titleState: "ready",
icon: ThemedGitCommitHorizontal,
statusBucket: null,
};
}
export const workingDiffPanelRegistration: PanelRegistration<"working_diff"> = {
kind: "working_diff",
component: WorkingDiffPanel,
useDescriptor: useWorkingDiffPanelDescriptor,
};
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
kind: "commit_diff",
component: CommitDiffPanel,
useDescriptor: useCommitDiffPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
},
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingRight: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
toolbarActions: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[1],
},
body: {
flex: 1,
minHeight: 0,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[16],
},
mutedText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
errorText: {
fontSize: theme.fontSize.base,
color: theme.colors.destructive,
textAlign: "center",
},
}));
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, type ReactNode } from "react";
import invariant from "tiny-invariant";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from "react";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
export interface PanelIconProps {
+1 -2
View File
@@ -1,6 +1,6 @@
import { agentPanelRegistration } from "@/panels/agent-panel";
import { browserPanelRegistration } from "@/panels/browser-panel";
import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel";
import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel";
import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel";
import { registerPanel } from "@/panels/panel-registry";
@@ -22,6 +22,5 @@ export function ensurePanelsRegistered(): void {
registerPanel(browserPanelRegistration);
registerPanel(filePanelRegistration);
registerPanel(commitDiffPanelRegistration);
registerPanel(workingDiffPanelRegistration);
panelsRegistered = true;
}
+1 -1
View File
@@ -13,7 +13,7 @@ import invariant from "tiny-invariant";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import type { Theme } from "@/styles/theme";
import {
@@ -2,6 +2,7 @@ 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 { resolveTone } from "./tone";
import type { ProviderUsageBalance, ProviderUsageTone } from "./types";
interface ResolvedBalance {
@@ -41,7 +42,7 @@ function fillToneStyle(tone: ProviderUsageTone) {
export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) {
const { amountText, usedPct } = resolveBalance(balance);
const tone = balance.tone ?? "default";
const tone = resolveTone(balance.tone, usedPct);
const resetLabel = formatResetLabel(balance.resetsAt);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { resolveTone } from "./tone";
describe("provider usage tone", () => {
it("shows the strongest risk from provider status and utilization", () => {
expect([
resolveTone("ok", 5),
resolveTone("ok", 70),
resolveTone("ok", 99),
resolveTone("danger", 5),
]).toEqual(["ok", "warning", "danger", "danger"]);
});
});
+18
View File
@@ -1,8 +1,26 @@
import type { ProviderUsageTone } from "./types";
const TONE_RISK: Record<ProviderUsageTone, number> = {
default: 0,
ok: 1,
warning: 2,
danger: 3,
};
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";
}
export function resolveTone(
providerTone: ProviderUsageTone | undefined,
usedPct: number | null | undefined,
): ProviderUsageTone {
const utilizationTone = deriveTone(usedPct);
if (!providerTone || TONE_RISK[utilizationTone] > TONE_RISK[providerTone]) {
return utilizationTone;
}
return providerTone;
}
@@ -2,7 +2,7 @@ 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 { resolveTone } from "./tone";
import type { ProviderUsageTone, ProviderUsageWindow } from "./types";
function resolveUsedPct(window: ProviderUsageWindow): number | null {
@@ -26,7 +26,7 @@ function fillToneStyle(tone: ProviderUsageTone) {
export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) {
const usedPct = resolveUsedPct(window);
const tone = window.tone ?? deriveTone(usedPct);
const tone = resolveTone(window.tone, usedPct);
const fillWidth = clampPct(usedPct ?? 0);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
@@ -78,7 +78,7 @@ import type { MessagePayload } from "@/composer/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
import {
getWorkspaceNamingAttachments,
@@ -1,6 +1,6 @@
import { expect, test } from "vitest";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { selectVisibleAgentIds } from "./visible-agent-ids";
test("selects only the active agent tab in every visible pane", () => {
@@ -1,5 +1,5 @@
import { collectAllPanes, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { deriveWorkspacePaneState } from "./workspace-pane-state";
export function selectVisibleAgentIds(input: {
@@ -444,7 +444,7 @@ interface WorkspaceDesktopTabsRowProps {
function getFallbackTabLabel(
tab: WorkspaceTabDescriptor,
labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string },
labels: { newAgent: string; setup: string; terminal: string; agent: string },
): string {
if (tab.target.kind === "draft") {
return labels.newAgent;
@@ -458,9 +458,6 @@ function getFallbackTabLabel(
if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
}
if (tab.target.kind === "working_diff") {
return labels.changes;
}
return labels.agent;
}
@@ -839,7 +836,6 @@ export function WorkspaceDesktopTabsRow({
setup: t("workspace.tabs.fallback.setup"),
terminal: t("workspace.tabs.fallback.terminal"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
}),
[t],
);
@@ -2,7 +2,7 @@ import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
} from "@/workspace/file-open";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
interface OpenWorkspaceFileFromExplorerInput {
filePath: string;
@@ -5,7 +5,7 @@ import {
resolveSideFileOpenPlacement,
} from "@/screens/workspace/workspace-pane-state";
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
function createTab(tabId: string, target: WorkspaceTab["target"]): WorkspaceTab {
return {
@@ -3,7 +3,7 @@ import {
type SplitPane,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
buildDeterministicWorkspaceTabId,
@@ -74,17 +74,14 @@ import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
getFocusedBrowserId,
type WorkspaceLayout,
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated,
} from "@/stores/workspace-layout-store";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
type WorkspaceTabTarget,
} from "@/workspace-tabs/model";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -348,7 +345,6 @@ function getFallbackTabOptionLabel(
terminal: string;
browser: string;
agent: string;
changes: string;
},
): string {
if (tab.target.kind === "draft") {
@@ -366,9 +362,6 @@ function getFallbackTabOptionLabel(
if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
}
if (tab.target.kind === "working_diff") {
return labels.changes;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
@@ -383,7 +376,6 @@ function getFallbackTabOptionDescription(
agent: string;
terminal: string;
browser: string;
changes: string;
},
): string {
if (tab.target.kind === "draft") {
@@ -407,9 +399,6 @@ function getFallbackTabOptionDescription(
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
if (tab.target.kind === "working_diff") {
return labels.changes;
}
return tab.target.path;
}
@@ -682,7 +671,6 @@ function MobileWorkspaceTabOption({
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
}),
[t],
);
@@ -2526,7 +2514,6 @@ function WorkspaceScreenContent({
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
}),
[t],
);
@@ -1,8 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
buildWorkspaceDesktopTabActions,
buildWorkspaceTabMenuEntries,
} from "@/screens/workspace/workspace-tab-menu";
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
function createAgentTab(): WorkspaceTabDescriptor {
@@ -256,37 +253,6 @@ describe("buildWorkspaceTabMenuEntries", () => {
expect(onCopyFilePath).toHaveBeenCalledWith("/some/path.ts");
});
it("uses a Changes close id for the working diff tab", () => {
const actions = buildWorkspaceDesktopTabActions({
tab: {
key: "working_diff_abc",
tabId: "working_diff_abc",
kind: "working_diff",
target: {
kind: "working_diff",
focusPath: "src/example.ts",
focusRequestId: 1,
},
},
index: 0,
tabCount: 1,
onCopyResumeCommand: vi.fn(),
onCopyAgentId: vi.fn(),
onCopyFilePath: vi.fn(),
onReloadAgent: vi.fn(),
onRenameTab: vi.fn(),
onCloseTab: vi.fn(),
onCloseTabsToLeft: vi.fn(),
onCloseTabsToRight: vi.fn(),
onCloseOtherTabs: vi.fn(),
});
expect(actions.closeButtonTestId).toMatch(/^workspace-working-diff-close-/);
expect(actions.menuEntries).not.toContainEqual(
expect.objectContaining({ kind: "item", key: "copy-file-path" }),
);
});
it("uses the same rename entry shape for agent and terminal tabs", () => {
const terminalTab: WorkspaceTabDescriptor = {
key: "terminal_abc",
@@ -144,9 +144,6 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "commit_diff") {
return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`;
}
if (tab.target.kind === "working_diff") {
return `workspace-working-diff-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`;
}
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
describe("deriveWorkspaceTabModel", () => {
it("keeps normalized tabs in stored order and preserves targets", () => {
@@ -1,4 +1,4 @@
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import {
deriveWorkspacePaneState,
type WorkspaceDerivedTab,
@@ -1,4 +1,4 @@
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
export interface WorkspaceTabDescriptor {
key: string;
+16 -39
View File
@@ -1,8 +1,7 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types";
import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file";
import type { AttachmentMetadata } from "@/attachments/types";
import {
garbageCollectAttachments,
persistAttachmentFromDataUrl,
@@ -40,21 +39,16 @@ interface DraftStoreActions {
draftKey: string;
lifecycle?: Exclude<DraftLifecycleState, "active">;
}) => void;
attachWorkspaceFile: (input: {
draftKey: string;
attachment: WorkspaceFileComposerAttachment;
}) => Promise<void>;
getCreateModalDraft: () => DraftInput | null;
saveCreateModalDraft: (draft: DraftInput | null) => void;
beginDraftGeneration: (draftKey: string) => number;
isDraftGenerationCurrent: (input: { draftKey: string; generation: number }) => boolean;
collectActiveAttachmentIds: () => string[];
}
interface DraftStoreRuntimeState {
attachmentFocusRequestByDraftKey: Record<string, number>;
}
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
type DraftStore = DraftStoreState & DraftStoreActions;
const draftGenerations = new Map<string, number>();
let gcScheduled = false;
const draftPersistStorage = createDraftPersistStorage(
createJSONStorage<DraftStoreState>(() => AsyncStorage),
@@ -243,7 +237,6 @@ export const useDraftStore = create<DraftStore>()(
(set, get) => ({
drafts: {},
createModalDraft: null,
attachmentFocusRequestByDraftKey: {},
getDraftInput: (draftKey) => {
const record = get().drafts[draftKey];
@@ -351,32 +344,7 @@ export const useDraftStore = create<DraftStore>()(
return { drafts: nextDrafts };
});
scheduleAttachmentGc();
},
attachWorkspaceFile: async ({ draftKey, attachment }) => {
await get().hydrateDraftInput({ draftKey });
set((state) => {
const existing = state.drafts[draftKey];
const draft = toDraftInputIfReady(existing) ?? { text: "", attachments: [] };
return {
drafts: {
...state.drafts,
[draftKey]: createDraftRecord({
draft: {
...draft,
attachments: appendWorkspaceFileAttachment(draft.attachments, attachment),
},
lifecycle: "active",
previousVersion: existing?.version,
}),
},
attachmentFocusRequestByDraftKey: {
...state.attachmentFocusRequestByDraftKey,
[draftKey]: (state.attachmentFocusRequestByDraftKey[draftKey] ?? 0) + 1,
},
};
});
draftGenerations.delete(draftKey);
scheduleAttachmentGc();
},
@@ -401,6 +369,16 @@ export const useDraftStore = create<DraftStore>()(
scheduleAttachmentGc();
},
beginDraftGeneration: (draftKey) => {
const next = (draftGenerations.get(draftKey) ?? 0) + 1;
draftGenerations.set(draftKey, next);
return next;
},
isDraftGenerationCurrent: ({ draftKey, generation }) => {
return (draftGenerations.get(draftKey) ?? 0) === generation;
},
collectActiveAttachmentIds: () => {
return Array.from(collectReferencedAttachmentIdsFromState(get()).values());
},
@@ -409,7 +387,6 @@ export const useDraftStore = create<DraftStore>()(
name: "paseo-drafts",
version: DRAFT_STORE_VERSION,
storage: draftPersistStorage,
partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }),
migrate: (persistedState) => {
return migratePersistedState(persistedState, {
migrateLegacyImages,
@@ -71,46 +71,6 @@ describe("draft-store lifecycle", () => {
});
describe("draft-store normalization", () => {
it("preserves uploaded file attachments when hydrating a draft", () => {
const attachment = {
kind: "file" as const,
attachment: {
type: "uploaded_file" as const,
id: "file-1",
fileName: "context.json",
mimeType: "application/json",
size: 42,
path: "/tmp/context.json",
},
};
expect(
toDraftInputIfReady({
input: { text: "Review this", attachments: [attachment] },
lifecycle: "active",
updatedAt: 1,
version: 1,
}),
).toEqual({ text: "Review this", attachments: [attachment] });
});
it("preserves workspace file selections when hydrating a draft", () => {
const attachment = {
kind: "workspace_file" as const,
path: "src/app.ts",
selection: { kind: "line_range" as const, startLine: 12, endLine: 24 },
};
expect(
toDraftInputIfReady({
input: { text: "Review this", attachments: [attachment] },
lifecycle: "active",
updatedAt: 1,
version: 1,
}),
).toEqual({ text: "Review this", attachments: [attachment] });
});
it("preserves New Workspace picker ownership when hydrating a draft", () => {
const pickerAttachment = {
kind: "github_pr" as const,
+1 -19
View File
@@ -3,12 +3,7 @@ import {
type AttachmentMetadata,
type UserComposerAttachment,
} from "@/attachments/types";
import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file";
import {
ForgeSearchItemSchema,
GitHubSearchItemSchema,
UploadedFileAttachmentSchema,
} from "@getpaseo/protocol/messages";
import { ForgeSearchItemSchema, GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
export const DRAFT_STORE_VERSION = 5;
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
@@ -88,12 +83,6 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
const metadata = record.metadata;
return isAttachmentMetadata(metadata);
}
if (record.kind === "workspace_file") {
return isWorkspaceFileComposerAttachment(value);
}
if (record.kind === "file") {
return UploadedFileAttachmentSchema.safeParse(record.attachment).success;
}
if (
record.kind !== "forge_issue" &&
record.kind !== "forge_change_request" &&
@@ -124,13 +113,6 @@ export function normalizeComposerAttachment(
metadata: normalizeAttachmentMetadata(attachment.metadata),
};
}
if (attachment.kind === "workspace_file") {
return {
kind: "workspace_file",
path: attachment.path.trim().replace(/^\.\//, ""),
selection: attachment.selection,
};
}
if (attachment.kind === "github_pr") {
const item =
(attachment.item as { kind: string }).kind === "pr"
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import {
navigateToLastWorkspace,
navigateToWorkspace,
@@ -11,7 +11,7 @@ import {
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-identity";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { prepareWorkspaceTab, type PrepareWorkspaceTabDeps } from "@/utils/prepare-workspace-tab";
export interface RouteSelectionInput {
@@ -1,7 +1,7 @@
import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
export interface PendingWorkspaceDraftSubmission {
serverId: string;
@@ -1,5 +1,5 @@
import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
@@ -285,14 +285,9 @@ function normalizeWorkspaceTab(value: unknown): WorkspaceTab | null {
const tab = value as WorkspaceTab;
const target = normalizeWorkspaceTabTarget(tab.target);
if (!target) {
return null;
}
const tabId =
target.kind === "working_diff"
? buildDeterministicWorkspaceTabId(target)
: (trimNonEmpty(tab.tabId) ?? buildDeterministicWorkspaceTabId(target));
if (!tabId) {
trimNonEmpty(tab.tabId) ?? (target ? buildDeterministicWorkspaceTabId(target) : null);
if (!target || !tabId) {
return null;
}
@@ -15,8 +15,9 @@ vi.mock("@react-native-async-storage/async-storage", () => {
};
});
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllPanes,
collectAllTabs,
createWorkspaceLayoutStore,
@@ -26,10 +27,8 @@ import {
getFocusedBrowserId,
getTreeDepth,
insertSplit,
normalizeLayout,
removePaneFromTree,
removeTabFromTree,
stripEphemeralTabsFromLayout,
type SplitNode,
type SplitPane,
} from "@/stores/workspace-layout-store";
@@ -1129,73 +1128,6 @@ describe("workspace-layout-store actions", () => {
]);
});
it("persists working diff tabs while stripping commit diff tabs", () => {
const workspaceKey = createWorkspaceKey();
const store = workspaceLayoutStore.getState();
store.openTabFocused(workspaceKey, {
kind: "working_diff",
focusPath: "src/a.ts",
});
store.openTabFocused(workspaceKey, { kind: "commit_diff", sha: "abc123" });
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
expect(partialize).toBeTypeOf("function");
if (!partialize) {
throw new Error("Workspace layout partialize function is missing");
}
const currentState = workspaceLayoutStore.getState();
const layout = stripEphemeralTabsFromLayout(currentState.layoutByWorkspace[workspaceKey]);
const persisted = partialize(currentState);
expect(persisted).toEqual({
layoutByWorkspace: { [workspaceKey]: layout },
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
});
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
{
kind: "working_diff",
focusPath: "src/a.ts",
},
]);
});
it("canonicalizes comparison-specific working diff tab ids from persisted layouts", () => {
const legacyTabId = "working_diff_uncommitted_0_n";
const layout = normalizeLayout({
root: {
kind: "pane",
pane: {
id: "main",
tabIds: [legacyTabId],
focusedTabId: legacyTabId,
tabs: [
{
tabId: legacyTabId,
createdAt: 1,
target: {
kind: "working_diff",
focusPath: "src/a.ts",
mode: "uncommitted",
baseRef: null,
ignoreWhitespace: false,
},
},
],
},
},
focusedPaneId: "main",
});
expect(collectAllTabs(layout.root)).toEqual([
{
tabId: "working_diff",
target: { kind: "working_diff", focusPath: "src/a.ts" },
createdAt: 1,
},
]);
});
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
useWorkspaceLayoutIds(
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
@@ -2,7 +2,11 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
type WorkspaceTabTarget,
} from "@/stores/workspace-tabs-store";
import {
defaultWorkspaceLayoutIds,
type WorkspaceLayoutIdSource,
@@ -43,6 +47,7 @@ import {
} from "@/stores/workspace-layout-actions";
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
export { buildWorkspaceTabPersistenceKey };
export {
collectAllPanes,
collectAllTabs,
@@ -1,6 +1,6 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
export type WorkspaceCreationMethod = "open_project" | "create_worktree";

Some files were not shown because too many files have changed in this diff Show More