mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00a940e74 | ||
|
|
c81c7bb7c0 | ||
|
|
d1f19a5cdd | ||
|
|
8a1243e8d3 | ||
|
|
dd8a111c30 | ||
|
|
780c6513f1 | ||
|
|
8b54d35818 | ||
|
|
e699f07a17 | ||
|
|
30b871e8d2 | ||
|
|
894fa8516e | ||
|
|
35f5171477 | ||
|
|
5dfe50ca74 | ||
|
|
de69b2a2af | ||
|
|
10da5ca169 | ||
|
|
512c9b31a8 | ||
|
|
76a5edb020 | ||
|
|
9952615c33 | ||
|
|
bd937850b3 | ||
|
|
42ee5a5949 | ||
|
|
68993b7ab3 | ||
|
|
246c07fba5 | ||
|
|
0afcc96370 | ||
|
|
3a8d08ba91 | ||
|
|
f22a8ea8e0 |
+27
-5
@@ -1,12 +1,32 @@
|
||||
# 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
|
||||
|
||||
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
|
||||
- Automate local and worktree-backed workspaces through the CLI and agent tools ([#2186](https://github.com/getpaseo/paseo/pull/2186))
|
||||
- Start a new workspace from a pasted pull request or merge request link ([#2290](https://github.com/getpaseo/paseo/pull/2290))
|
||||
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
|
||||
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
|
||||
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
|
||||
@@ -15,9 +35,11 @@
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspace and chat sync uses less data without losing or reordering history ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
|
||||
- Added folders remain independent projects across nested repositories, worktrees, and filesystem mounts ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
|
||||
- Idle agents release provider resources automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
|
||||
- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186))
|
||||
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
|
||||
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
|
||||
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
|
||||
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
|
||||
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
|
||||
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
|
||||
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
|
||||
|
||||
@@ -80,6 +80,13 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
|
||||
History navigation preserves the selected agent as an explicit recovery target. If both that agent
|
||||
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
|
||||
the selected agent as one user action. Other archived agents in the restored workspace remain
|
||||
recoverable from History. Opening one pins its tab and renders the archived-agent callout before any
|
||||
provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex
|
||||
`thread/unarchive`) before the normal agent resume and timeline hydration flow.
|
||||
|
||||
## Tabs vs archive
|
||||
|
||||
These are two distinct concepts that used to be conflated:
|
||||
|
||||
+7
-1
@@ -383,13 +383,14 @@ install.
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
@@ -400,6 +401,11 @@ 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:
|
||||
|
||||
@@ -73,6 +73,20 @@ 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
@@ -1 +1 @@
|
||||
sha256-prXQpWLMwN1JO2tGhGDie/Ud9vnmUSF44581Mm7R7eA=
|
||||
sha256-x6jiaw7zkCuBRlddLHD0tXQxolXwr7tzIQBfWu5xnUU=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35211,7 +35211,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"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.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.0-beta.2",
|
||||
"@getpaseo/protocol": "0.2.0-beta.2",
|
||||
"@getpaseo/server": "0.2.0-beta.2",
|
||||
"@getpaseo/client": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/server": "0.2.0-beta.3",
|
||||
"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.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.2",
|
||||
"@getpaseo/relay": "0.2.0-beta.2",
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/relay": "0.2.0-beta.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36501,7 +36501,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"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.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37640,7 +37640,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"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.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37885,7 +37885,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38103,15 +38103,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"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.2",
|
||||
"@getpaseo/highlight": "0.2.0-beta.2",
|
||||
"@getpaseo/protocol": "0.2.0-beta.2",
|
||||
"@getpaseo/relay": "0.2.0-beta.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",
|
||||
"@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.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { openSessions } from "./helpers/archive-tab";
|
||||
import {
|
||||
assertChatTranscript,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
} from "./helpers/rewind-flow";
|
||||
import type { SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
interface TimelineClient extends SeedDaemonClient {
|
||||
fetchAgentTimeline(
|
||||
agentId: string,
|
||||
options: { direction: "tail"; projection: "projected"; limit: number },
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
|
||||
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
|
||||
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
|
||||
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
|
||||
|
||||
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
|
||||
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
|
||||
return history.entries.some((entry) => entry.agent.id === agentId);
|
||||
}
|
||||
|
||||
test.describe("archived Codex agent recovery", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
});
|
||||
await sendMessage(handle, INITIAL_PROMPT);
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
]);
|
||||
|
||||
await handle.client.archiveAgent(handle.agentId);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
|
||||
null,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const timelineClient = handle.client as TimelineClient;
|
||||
await expect(
|
||||
timelineClient.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
limit: 100,
|
||||
}),
|
||||
).rejects.toThrow(/archiv/i);
|
||||
await expect
|
||||
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
]);
|
||||
|
||||
await sendMessage(handle, FOLLOW_UP_PROMPT);
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
{ role: "user", text: FOLLOW_UP_PROMPT },
|
||||
{ role: "assistant", text: FOLLOW_UP_REPLY },
|
||||
]);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -28,26 +28,20 @@ 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 });
|
||||
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await panel.getByTestId("commit-diff-layout-split").click();
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await layoutToggle.click();
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
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-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
|
||||
"Switch to unified diff",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { unlink, 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,6 +10,11 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
@@ -218,6 +223,99 @@ 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);
|
||||
@@ -243,6 +341,11 @@ 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);
|
||||
|
||||
@@ -250,6 +353,53 @@ 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,
|
||||
}) => {
|
||||
@@ -399,10 +549,14 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
||||
});
|
||||
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 });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
@@ -412,13 +566,16 @@ async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
});
|
||||
|
||||
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 };
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
@@ -436,6 +593,21 @@ 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({
|
||||
|
||||
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
@@ -34,6 +38,7 @@ 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";
|
||||
@@ -158,6 +163,43 @@ 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,
|
||||
@@ -420,12 +462,13 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
|
||||
await page.evaluate(
|
||||
({ agentId, serverId: targetServerId }) => {
|
||||
({ agentId, serverId: targetServerId, workspaceId }) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: {
|
||||
data: {
|
||||
serverId: targetServerId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
reason: "finished",
|
||||
},
|
||||
@@ -434,7 +477,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ agentId: secondAgent.id, serverId },
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
|
||||
@@ -44,7 +44,10 @@ test.describe("Worktree restore", () => {
|
||||
tempRepo = await createTempGitRepo("wt-restore-");
|
||||
});
|
||||
|
||||
async function createArchivedMissingWorktree(prefix: string) {
|
||||
async function createArchivedMissingWorktree(
|
||||
prefix: string,
|
||||
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
|
||||
) {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
@@ -53,11 +56,19 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const agents = await Promise.all(
|
||||
Array.from({ length: options.agentCount ?? 1 }, () =>
|
||||
createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const agent = agents[0];
|
||||
if (!agent) {
|
||||
throw new Error("Expected at least one archived-worktree agent");
|
||||
}
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
@@ -67,11 +78,21 @@ test.describe("Worktree restore", () => {
|
||||
// Match the remote cloud-race record: workspace archived and absent, while
|
||||
// the surviving closed agent record is not agent-archived. Refresh now owns
|
||||
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
if (options.keepAgentsArchived) {
|
||||
for (const archivedAgent of agents) {
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
|
||||
.not.toBeNull();
|
||||
}
|
||||
} else {
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
}
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
return { agent, worktree };
|
||||
return { agent, agents, worktree };
|
||||
}
|
||||
|
||||
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
||||
@@ -241,6 +262,54 @@ test.describe("Worktree restore", () => {
|
||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
|
||||
agentCount: 2,
|
||||
keepAgentsArchived: true,
|
||||
});
|
||||
const [firstAgent, secondAgent] = agents;
|
||||
if (!firstAgent || !secondAgent) {
|
||||
throw new Error("Expected two archived agents");
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
|
||||
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.2",
|
||||
"version": "0.2.0-beta.3",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -257,6 +257,35 @@ 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,6 +68,8 @@ interface BottomAnchorControllerDriver {
|
||||
resetForAgent: () => void;
|
||||
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
|
||||
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
|
||||
beginUserScroll: () => void;
|
||||
endUserScroll: () => void;
|
||||
detachByUser: () => void;
|
||||
handleViewportMetricsChange: (params: {
|
||||
previousViewportWidth: number;
|
||||
@@ -243,6 +245,7 @@ function createBottomAnchorControllerDriver(
|
||||
let lastRouteRequestKey: string | null = null;
|
||||
let stickyMeasurementRevision = 0;
|
||||
let lastVerifiedStickyMeasurementRevision = 0;
|
||||
let isUserScrollActive = false;
|
||||
|
||||
const setBlockedReason = (nextBlockedReason: BottomAnchorBlockedReason | null) => {
|
||||
if (blockedReason === nextBlockedReason) {
|
||||
@@ -481,6 +484,7 @@ function createBottomAnchorControllerDriver(
|
||||
cancelPendingAttempt();
|
||||
stickyMeasurementRevision = 0;
|
||||
lastVerifiedStickyMeasurementRevision = 0;
|
||||
isUserScrollActive = false;
|
||||
mode = "sticky-bottom";
|
||||
input.onModeChange("sticky-bottom");
|
||||
},
|
||||
@@ -497,6 +501,21 @@ 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;
|
||||
@@ -511,6 +530,9 @@ function createBottomAnchorControllerDriver(
|
||||
) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnViewportChange({
|
||||
mode,
|
||||
previousViewportWidth: params.previousViewportWidth,
|
||||
@@ -529,6 +551,9 @@ function createBottomAnchorControllerDriver(
|
||||
if (params.previousContentHeight !== params.contentHeight) {
|
||||
markStickyMeasurementChanged();
|
||||
}
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
const shouldRestick = __private__.shouldRestickOnContentChange({
|
||||
mode,
|
||||
previousContentHeight: params.previousContentHeight,
|
||||
@@ -558,6 +583,9 @@ function createBottomAnchorControllerDriver(
|
||||
return;
|
||||
}
|
||||
markStickyMeasurementChanged();
|
||||
if (isUserScrollActive) {
|
||||
return;
|
||||
}
|
||||
if (!pendingRequest) {
|
||||
pendingVerification = { requestId: null, retries: 0 };
|
||||
if (attemptHandle) {
|
||||
@@ -571,6 +599,12 @@ function createBottomAnchorControllerDriver(
|
||||
},
|
||||
handleScrollNearBottomChange(params) {
|
||||
const { nextIsNearBottom, scrollDelta } = params;
|
||||
if (isUserScrollActive) {
|
||||
if (mode === "sticky-bottom" && !nextIsNearBottom) {
|
||||
this.detachByUser();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
nextIsNearBottom &&
|
||||
mode === "sticky-bottom" &&
|
||||
@@ -737,6 +771,12 @@ export function useBottomAnchorController(input: {
|
||||
requestLocalAnchor(request: BottomAnchorLocalRequest) {
|
||||
driverRef.current?.requestLocalAnchor(request);
|
||||
},
|
||||
beginUserScroll() {
|
||||
driverRef.current?.beginUserScroll();
|
||||
},
|
||||
endUserScroll() {
|
||||
driverRef.current?.endUserScroll();
|
||||
},
|
||||
detachByUser() {
|
||||
driverRef.current?.detachByUser();
|
||||
},
|
||||
|
||||
@@ -89,6 +89,8 @@ 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);
|
||||
@@ -129,6 +131,13 @@ 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);
|
||||
@@ -208,6 +217,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
contentMeasuredForKey: null,
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
isUserScrollActiveRef.current = false;
|
||||
clearPendingUserScrollEnd();
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(false);
|
||||
historyStartReadyRef.current = false;
|
||||
@@ -216,8 +227,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearPendingUserScrollEnd();
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling]);
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardEvents = [
|
||||
@@ -301,7 +313,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onNearHistoryStart();
|
||||
}
|
||||
|
||||
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
|
||||
if (
|
||||
!isUserScrollActiveRef.current &&
|
||||
programmaticScrollEventBudgetRef.current > 0 &&
|
||||
contentOffset.y <= 8
|
||||
) {
|
||||
programmaticScrollEventBudgetRef.current -= 1;
|
||||
} else {
|
||||
programmaticScrollEventBudgetRef.current = 0;
|
||||
@@ -312,6 +328,31 @@ 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;
|
||||
@@ -419,6 +460,10 @@ 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}
|
||||
|
||||
@@ -99,7 +99,7 @@ import {
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ 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";
|
||||
@@ -146,9 +147,10 @@ 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 && agentId) {
|
||||
navigateToAgent({ serverId, agentId, pin: true });
|
||||
if (serverId && workspaceId && agentId) {
|
||||
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -908,6 +910,7 @@ function AppShell() {
|
||||
<MobilePanelsProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AgentNavigationListener />
|
||||
<AppWithSidebar>
|
||||
<WorkspaceRouteNavigationBridge />
|
||||
<RootStack />
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, 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 { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -21,97 +24,128 @@ function HostAgentReadyRouteContent() {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
const redirectedRef = useRef(false);
|
||||
const handledNavigationRef = useRef<string | null>(null);
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
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 agentWorkspaceId = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||
});
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
const shouldLookupAgent = Boolean(
|
||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||
);
|
||||
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
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,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
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 (!serverId || !agentId) {
|
||||
redirectedRef.current = true;
|
||||
router.replace("/" as Href);
|
||||
if (!navigationKey || handledNavigationRef.current === navigationKey) {
|
||||
return;
|
||||
}
|
||||
handledNavigationRef.current = navigationKey;
|
||||
|
||||
if (resolvedWorkspaceId) {
|
||||
redirectedRef.current = true;
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
});
|
||||
if (resolution.kind === "resolved") {
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||
return;
|
||||
}
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||
}, [agentId, resolution, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
const handleRetry = useCallback(() => {
|
||||
if (resolution.kind === "lookupError") {
|
||||
void lookupQuery.refetch();
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
if (agentWorkspaceId && !hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
}
|
||||
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
||||
}, [router, serverId]);
|
||||
|
||||
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]);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
@@ -110,7 +110,9 @@ function HostWorkspaceRouteContent() {
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
|
||||
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
|
||||
const isAgentOpenIntent = recoveryAgentId !== null;
|
||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
||||
);
|
||||
@@ -147,7 +149,6 @@ function HostWorkspaceRouteContent() {
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
@@ -171,6 +172,7 @@ function HostWorkspaceRouteContent() {
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
isOpenIntentWaitingForWorkspace,
|
||||
navigation,
|
||||
openIntent,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
serverId,
|
||||
@@ -185,10 +187,16 @@ function HostWorkspaceRouteContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
function WorkspaceDeck({
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
}: {
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
}) {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
@@ -234,6 +242,7 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
selection={selection}
|
||||
activeSelection={activeSelection}
|
||||
recoveryRequested={recoveryRequested}
|
||||
recoveryAgentId={recoveryAgentId}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
@@ -246,11 +255,13 @@ function WorkspaceDeckEntry({
|
||||
selection,
|
||||
activeSelection,
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
onUnmountInactive,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
activeSelection: ActiveWorkspaceSelection;
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
@@ -282,6 +293,7 @@ function WorkspaceDeckEntry({
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
recoveryRequested={isActive && recoveryRequested}
|
||||
recoveryAgentId={isActive ? recoveryAgentId : null}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
);
|
||||
|
||||
@@ -92,9 +92,20 @@ 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { WorkspaceFileSelection } from "./types";
|
||||
|
||||
export interface WorkspaceFileDragSourceInput {
|
||||
enabled: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string;
|
||||
workspaceId: string | null | undefined;
|
||||
path: string;
|
||||
selection?: WorkspaceFileSelection;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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)],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
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,10 +4,8 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||
import { mountBrowserAutomationHandler } from "./handler";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
|
||||
@@ -6,11 +6,8 @@ import {
|
||||
resizeResidentBrowserWebview,
|
||||
} from "@/components/browser-webview-resident";
|
||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
|
||||
export type ClientSlashCommandExecution = "immediate" | "insert";
|
||||
|
||||
@@ -394,6 +394,7 @@ export function AgentList({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: agent.workspaceId,
|
||||
pin: true,
|
||||
});
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
interface ArchivedAgentCalloutProps {
|
||||
serverId: string;
|
||||
@@ -21,6 +22,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
||||
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
|
||||
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
|
||||
|
||||
@@ -32,10 +34,11 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
const handleUnarchive = useCallback(async () => {
|
||||
if (!client || !isConnected || isUnarchiving) return;
|
||||
setIsUnarchiving(true);
|
||||
setUnarchiveError(null);
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
} catch (error) {
|
||||
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
|
||||
setUnarchiveError(toErrorMessage(error));
|
||||
setIsUnarchiving(false);
|
||||
}
|
||||
}, [client, isConnected, isUnarchiving, agentId]);
|
||||
@@ -44,16 +47,23 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
<Animated.View style={containerStyle}>
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
>
|
||||
{t("agentPanel.archived.unarchive")}
|
||||
</Button>
|
||||
<View style={styles.calloutStack}>
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
>
|
||||
{t("agentPanel.archived.unarchive")}
|
||||
</Button>
|
||||
</View>
|
||||
{unarchiveError ? (
|
||||
<Text style={styles.errorText} testID="agent-unarchive-error">
|
||||
{unarchiveError}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -97,8 +107,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
md: theme.spacing[6],
|
||||
},
|
||||
},
|
||||
calloutStack: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
calloutText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
})) as unknown as Record<string, object>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
interface DiffStatProps {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
@@ -15,9 +16,9 @@ export function formatDiffCount(value: number): string {
|
||||
return compactFormatter.format(value).toLowerCase();
|
||||
}
|
||||
|
||||
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.row} testID={testID}>
|
||||
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||
</View>
|
||||
|
||||
@@ -36,6 +36,11 @@ 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 {}
|
||||
|
||||
@@ -386,15 +391,16 @@ function ExplorerSidebarContent({
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{resolvedTab === "changes" && (
|
||||
<GitDiffPane
|
||||
<ChangedFilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "files" && (
|
||||
<FileExplorerPane
|
||||
<FilesPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
@@ -415,6 +421,83 @@ 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;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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,4 +1,5 @@
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
|
||||
|
||||
export interface DroppedFileItem {
|
||||
kind: "web-file";
|
||||
@@ -18,4 +19,5 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
export interface FileDropSink {
|
||||
onFiles: (images: ImageAttachment[]) => void;
|
||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ 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[] }
|
||||
@@ -185,7 +189,11 @@ export function useDropListeners({
|
||||
if (disabledRef.current) return;
|
||||
|
||||
dragCounter.current++;
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
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) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
}
|
||||
@@ -197,7 +205,11 @@ 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 canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
|
||||
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;
|
||||
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
||||
}
|
||||
|
||||
@@ -225,6 +237,14 @@ 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",
|
||||
|
||||
@@ -15,35 +15,26 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
MoreVertical,
|
||||
RotateCw,
|
||||
} from "lucide-react-native";
|
||||
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
||||
import {
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} 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 { useDownloadStore } from "@/stores/download-store";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
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" },
|
||||
@@ -62,6 +53,8 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
}
|
||||
|
||||
interface TreeRowItemProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
entry: ExplorerEntry;
|
||||
depth: number;
|
||||
isExpanded: boolean;
|
||||
@@ -70,21 +63,8 @@ interface TreeRowItemProps {
|
||||
onEntryPress: (entry: ExplorerEntry) => void;
|
||||
onCopyPath: (path: string) => void;
|
||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
onAddToChat?: (path: string) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function sortTriggerStyle({
|
||||
@@ -103,6 +83,8 @@ function treeRowKeyExtractor(row: TreeRow) {
|
||||
}
|
||||
|
||||
function TreeRowItem({
|
||||
serverId,
|
||||
workspaceId,
|
||||
entry,
|
||||
depth,
|
||||
isExpanded,
|
||||
@@ -111,10 +93,17 @@ 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);
|
||||
@@ -123,10 +112,10 @@ function TreeRowItem({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.entryRow,
|
||||
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
|
||||
{ paddingLeft: treeRowPaddingLeft(depth) },
|
||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
||||
],
|
||||
[depth, isSelected, theme.spacing],
|
||||
[depth, isSelected],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
@@ -137,19 +126,38 @@ function TreeRowItem({
|
||||
onDownloadEntry(entry);
|
||||
}, [onDownloadEntry, entry]);
|
||||
|
||||
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],
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
||||
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<View style={styles.entryInfo}>
|
||||
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
{(() => {
|
||||
if (!isDirectory) {
|
||||
@@ -163,40 +171,15 @@ function TreeRowItem({
|
||||
{entry.name}
|
||||
</Text>
|
||||
</View>
|
||||
<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>
|
||||
<FileActionsMenu
|
||||
fileKind={entry.kind}
|
||||
onCopyPath={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||
header={metaHeader}
|
||||
accessibilityLabel={t("workspace.fileActions.moreActions")}
|
||||
testIDPrefix={testID}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -206,6 +189,7 @@ interface FileExplorerPaneProps {
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
@@ -218,14 +202,10 @@ 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(
|
||||
() =>
|
||||
@@ -235,10 +215,6 @@ 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]
|
||||
@@ -246,12 +222,16 @@ export function FileExplorerPane({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
||||
useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const downloadFile = useFileDownload({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
@@ -346,18 +326,14 @@ export function FileExplorerPane({
|
||||
[normalizedWorkspaceRoot],
|
||||
);
|
||||
|
||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||
const handleDownloadEntry = useCallback(
|
||||
(entry: ExplorerEntry) =>
|
||||
downloadExplorerEntry({
|
||||
entry,
|
||||
workspaceScopeId,
|
||||
serverId,
|
||||
daemonProfile,
|
||||
startDownload,
|
||||
requestFileDownloadToken,
|
||||
}),
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
(entry: ExplorerEntry) => {
|
||||
if (entry.kind !== "file") {
|
||||
return;
|
||||
}
|
||||
downloadFile({ fileName: entry.name, path: entry.path });
|
||||
},
|
||||
[downloadFile],
|
||||
);
|
||||
|
||||
const handleSortCycle = useCallback(() => {
|
||||
@@ -419,6 +395,8 @@ export function FileExplorerPane({
|
||||
const renderTreeRow = useCallback(
|
||||
(info: ListRenderItemInfo<TreeRow>) => (
|
||||
<TreeRowDispatcher
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
info={info}
|
||||
expandedPaths={expandedPaths}
|
||||
selectedEntryPath={selectedEntryPath}
|
||||
@@ -426,6 +404,7 @@ export function FileExplorerPane({
|
||||
onEntryPress={handleEntryPress}
|
||||
onCopyPath={handleCopyPath}
|
||||
onDownloadEntry={handleDownloadEntry}
|
||||
onAddToChat={onAddToChat}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -435,6 +414,9 @@ export function FileExplorerPane({
|
||||
handleDownloadEntry,
|
||||
isDirectoryLoading,
|
||||
selectedEntryPath,
|
||||
onAddToChat,
|
||||
serverId,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -576,8 +558,14 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
return (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
|
||||
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
||||
<Pressable
|
||||
onPress={handleSortCycle}
|
||||
style={sortTriggerStyleProp}
|
||||
testID="files-sort-trigger"
|
||||
>
|
||||
<Text style={styles.sortTriggerText} testID="files-sort-label">
|
||||
{currentSortLabel}
|
||||
</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.headerActions}>
|
||||
@@ -588,6 +576,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
||||
accessibilityState={hiddenFilesToggleAccessibilityState}
|
||||
testID="files-hidden-toggle"
|
||||
>
|
||||
{showHiddenFiles ? (
|
||||
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
@@ -606,6 +595,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
? t("workspace.fileExplorer.actions.refreshing")
|
||||
: t("workspace.fileExplorer.actions.refresh")
|
||||
}
|
||||
testID="files-refresh"
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
@@ -778,39 +768,6 @@ 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,
|
||||
@@ -850,6 +807,8 @@ function toggleDirectory({
|
||||
}
|
||||
|
||||
function TreeRowDispatcher({
|
||||
serverId,
|
||||
workspaceId,
|
||||
info,
|
||||
expandedPaths,
|
||||
selectedEntryPath,
|
||||
@@ -857,7 +816,10 @@ function TreeRowDispatcher({
|
||||
onEntryPress,
|
||||
onCopyPath,
|
||||
onDownloadEntry,
|
||||
onAddToChat,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
info: ListRenderItemInfo<TreeRow>;
|
||||
expandedPaths: Set<string>;
|
||||
selectedEntryPath: string | null;
|
||||
@@ -865,6 +827,7 @@ 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;
|
||||
@@ -875,6 +838,8 @@ function TreeRowDispatcher({
|
||||
|
||||
return (
|
||||
<TreeRowItem
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
entry={entry}
|
||||
depth={depth}
|
||||
isExpanded={isExpanded}
|
||||
@@ -883,6 +848,8 @@ function TreeRowDispatcher({
|
||||
onEntryPress={onEntryPress}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownloadEntry={onDownloadEntry}
|
||||
onAddToChat={onAddToChat}
|
||||
testID={`file-explorer-row-${info.index}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1061,7 +1028,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
entriesContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
@@ -1111,9 +1077,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 2,
|
||||
paddingRight: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
paddingRight: theme.spacing[3],
|
||||
},
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
@@ -1133,16 +1098,6 @@ 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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
@@ -11,6 +11,7 @@ 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. */
|
||||
|
||||
@@ -338,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
|
||||
input.setLightboxMetadata(input.attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (input.attachment.kind === "file") {
|
||||
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceAttachment(input.attachment)) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildForgeAttachmentFromSearchItem,
|
||||
buildLegacyGitHubAttachmentFromSearchItem,
|
||||
} from "@/utils/review-attachments";
|
||||
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
|
||||
|
||||
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
||||
|
||||
@@ -49,6 +50,11 @@ 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,6 +5,7 @@ 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>(),
|
||||
@@ -159,7 +160,11 @@ describe("useAgentInputDraft live contract", () => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||
useDraftStore.setState({
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
|
||||
@@ -424,6 +429,39 @@ 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 = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, 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,7 +9,6 @@ import {
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import {
|
||||
areAttachmentsEqual,
|
||||
buildDraftAgentControls,
|
||||
hasDraftContent,
|
||||
resolveDraftKey,
|
||||
@@ -22,6 +21,7 @@ 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,6 +57,7 @@ export interface AgentInputDraft {
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
isHydrated: boolean;
|
||||
attachmentFocusRequestId: number;
|
||||
composerState: DraftComposerState | null;
|
||||
}
|
||||
|
||||
@@ -77,67 +78,66 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
}),
|
||||
[formState.selectedServerId, input.draftKey],
|
||||
);
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const draftGenerationRef = useRef(0);
|
||||
const hydratedGenerationRef = useRef(0);
|
||||
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 setAttachments = useCallback((updater: AttachmentUpdater) => {
|
||||
setAttachmentsState((previousAttachments) => {
|
||||
if (typeof updater === "function") {
|
||||
return updater(previousAttachments);
|
||||
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;
|
||||
}
|
||||
return updater;
|
||||
});
|
||||
}, []);
|
||||
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],
|
||||
);
|
||||
|
||||
const clear = useCallback(
|
||||
(lifecycle: "sent" | "abandoned") => {
|
||||
const store = useDraftStore.getState();
|
||||
store.clearDraftInput({ draftKey, lifecycle });
|
||||
|
||||
const generation = store.beginDraftGeneration(draftKey);
|
||||
draftGenerationRef.current = generation;
|
||||
hydratedGenerationRef.current = generation;
|
||||
|
||||
setText("");
|
||||
setAttachmentsState([]);
|
||||
setIsHydrated(true);
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
||||
},
|
||||
[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 () => {
|
||||
const draft = await store.hydrateDraftInput({
|
||||
draftKey,
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||
if (!cancelled) {
|
||||
setHydratedDraftKey(draftKey);
|
||||
}
|
||||
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
setText(draft.text);
|
||||
setAttachmentsState(draft.attachments);
|
||||
}
|
||||
|
||||
hydratedGenerationRef.current = generation;
|
||||
setIsHydrated(true);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -145,52 +145,6 @@ 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) {
|
||||
@@ -304,6 +258,7 @@ 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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||
@@ -751,6 +751,7 @@ export function WorkspaceDraftAgentTab({
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
@@ -765,6 +766,7 @@ export function WorkspaceDraftAgentTab({
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
}
|
||||
@@ -99,6 +99,7 @@ import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceFileComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { PickedFile } from "@/attachments/picked-file";
|
||||
@@ -119,6 +120,15 @@ 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;
|
||||
|
||||
@@ -399,6 +409,18 @@ 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,
|
||||
@@ -711,6 +733,43 @@ 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;
|
||||
@@ -755,6 +814,7 @@ function GithubPickerOption({
|
||||
interface ComposerProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
isPaneFocused: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
|
||||
@@ -786,6 +846,8 @@ 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. */
|
||||
@@ -977,6 +1039,7 @@ function ComposerVoiceModeButton({
|
||||
export function Composer({
|
||||
agentId,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isPaneFocused,
|
||||
onSubmitMessage,
|
||||
onClientSlashCommand,
|
||||
@@ -1000,6 +1063,7 @@ export function Composer({
|
||||
cwd,
|
||||
clearDraft,
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
onFocusInput,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
@@ -1195,6 +1259,21 @@ 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]);
|
||||
@@ -1969,7 +2048,11 @@ 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 },
|
||||
{
|
||||
onFiles: addImages,
|
||||
onGenericFiles: handleGenericFilesDropped,
|
||||
onWorkspaceFile: handleWorkspaceFileDropped,
|
||||
},
|
||||
{ disabled: isSubmitBusy },
|
||||
);
|
||||
|
||||
@@ -2030,7 +2113,7 @@ export function Composer({
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
|
||||
@@ -29,6 +29,7 @@ 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";
|
||||
@@ -154,6 +155,35 @@ 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" }
|
||||
@@ -500,16 +530,20 @@ 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 =
|
||||
params.notification ??
|
||||
buildAgentAttentionNotificationPayload({
|
||||
reason: params.reason,
|
||||
serverId,
|
||||
agentId: params.agentId,
|
||||
assistantMessage: params.reason === "finished" ? assistantMessage : null,
|
||||
permissionRequest: params.reason === "permission" ? permissionRequest : null,
|
||||
});
|
||||
const notification = resolveAgentAttentionNotification({
|
||||
notification: params.notification,
|
||||
reason: params.reason,
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId: params.agentId,
|
||||
assistantMessage,
|
||||
permissionRequest,
|
||||
});
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
|
||||
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.21",
|
||||
version: "0.4.22",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.4.21", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.22", "--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.20",
|
||||
version: "0.9.21",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.20", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.21", "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.2",
|
||||
version: "1.1.3",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.2", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
}
|
||||
@@ -123,6 +123,10 @@ 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" };
|
||||
@@ -169,6 +173,7 @@ export interface DesktopHostBridge {
|
||||
platform?: string;
|
||||
invoke?: DesktopInvokeBridge["invoke"];
|
||||
getPendingOpenProject?: () => Promise<string | null>;
|
||||
agentNavigation?: DesktopAgentNavigationBridge;
|
||||
events?: DesktopEventsBridge;
|
||||
window?: DesktopWindowModuleBridge;
|
||||
dialog?: DesktopDialogBridge;
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
|
||||
import { FILE_ACTIONS_MENU_WIDTH } from "@/components/file-actions-menu";
|
||||
import {
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
import { type Theme } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
@@ -80,7 +86,12 @@ export function DiffFolderRow({
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.right}>
|
||||
<DiffStat additions={additions} deletions={deletions} />
|
||||
<DiffStat
|
||||
additions={additions}
|
||||
deletions={deletions}
|
||||
testID={testID ? `${testID}-stat` : undefined}
|
||||
/>
|
||||
<View style={styles.actionSlot} />
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -94,8 +105,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
folderRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
},
|
||||
@@ -113,6 +124,10 @@ 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,
|
||||
|
||||
+727
-408
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ interface UseCheckoutDiffQueryOptions {
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
queryScope?: string;
|
||||
}
|
||||
|
||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
@@ -44,6 +45,7 @@ export function useCheckoutDiffQuery({
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
queryScope,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const normalizedCompare = useMemo(
|
||||
@@ -53,10 +55,17 @@ export function useCheckoutDiffQuery({
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
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 subscriptionId = useMemo(() => `checkoutDiff:${JSON.stringify(queryKey)}`, [queryKey]);
|
||||
const routeEnabled = Boolean(enabled && isConnected && cwd);
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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]);
|
||||
}
|
||||
@@ -57,6 +57,7 @@ function createAgentWithStatus({ id, status }: { id: string; status: Agent["stat
|
||||
function createBaseInput(): AgentScreenMachineInput {
|
||||
return {
|
||||
agent: null,
|
||||
isArchived: false,
|
||||
continuity: { kind: "none" },
|
||||
missingAgentState: { kind: "idle" },
|
||||
isConnected: true,
|
||||
@@ -385,6 +386,22 @@ describe("deriveAgentScreenViewState", () => {
|
||||
expect(result.memory.lastReadyAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("renders an archived agent before provider history is initialized", () => {
|
||||
const result = deriveAgentScreenViewState({
|
||||
input: {
|
||||
...createBaseInput(),
|
||||
agent: createAgent("agent-1"),
|
||||
isArchived: true,
|
||||
needsAuthoritativeSync: true,
|
||||
},
|
||||
memory: createBaseMemory(),
|
||||
});
|
||||
|
||||
const ready = expectReadyState(result.state);
|
||||
expect(ready.agent.id).toBe("agent-1");
|
||||
expect(ready.sync).toEqual({ status: "idle" });
|
||||
});
|
||||
|
||||
it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => {
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
|
||||
@@ -41,6 +41,7 @@ export type AgentScreenMissingState =
|
||||
|
||||
export interface AgentScreenMachineInput {
|
||||
agent: AgentScreenAgent | null;
|
||||
isArchived: boolean;
|
||||
missingAgentState: AgentScreenMissingState;
|
||||
isConnected: boolean;
|
||||
isArchivingCurrentAgent: boolean;
|
||||
@@ -61,6 +62,7 @@ function hasOptimisticCreateContinuity(input: AgentScreenMachineInput): boolean
|
||||
|
||||
function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean {
|
||||
return (
|
||||
!input.isArchived &&
|
||||
!hasOptimisticCreateContinuity(input) &&
|
||||
!input.hasHydratedHistoryBefore &&
|
||||
(input.needsAuthoritativeSync || input.isHistorySyncing)
|
||||
@@ -164,6 +166,9 @@ function resolveAgentScreenSync(args: {
|
||||
hadInitialSyncFailure: boolean;
|
||||
}): AgentScreenReadySyncState {
|
||||
const { input, hadInitialSyncFailure } = args;
|
||||
if (input.isArchived) {
|
||||
return { status: "idle" };
|
||||
}
|
||||
if (!input.isConnected) {
|
||||
return { status: "reconnecting" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -366,6 +366,13 @@ export const ar: TranslationResources = {
|
||||
copyBranchName: "نسخ اسم الفرع",
|
||||
copied: "تم النسخ",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "افتح الملف",
|
||||
copyPath: "نسخ المسار",
|
||||
download: "تحميل",
|
||||
addToChat: "إضافة إلى الدردشة…",
|
||||
moreActions: "المزيد من الإجراءات",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "اسم",
|
||||
@@ -375,8 +382,6 @@ export const ar: TranslationResources = {
|
||||
context: {
|
||||
size: "مقاس",
|
||||
modified: "معدل",
|
||||
copyPath: "نسخ المسار",
|
||||
download: "تحميل",
|
||||
},
|
||||
actions: {
|
||||
back: "خلف",
|
||||
@@ -753,6 +758,8 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "فتح علامة تبويب التغييرات",
|
||||
closeChangesTab: "إغلاق علامة تبويب التغييرات",
|
||||
binaryFile: "ملف ثنائي",
|
||||
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
|
||||
unified: "الفرق الموحدة",
|
||||
@@ -1489,6 +1496,8 @@ export const ar: TranslationResources = {
|
||||
changesLabel: "التغييرات",
|
||||
changesSubtitle: "فروقات شجرة العمل",
|
||||
commitSubtitle: "فروقات الالتزام",
|
||||
uncommittedSubtitle: "تغييرات غير ملتزم بها",
|
||||
baseSubtitle: "مقارنة مع {{baseRef}}",
|
||||
directoryMissing: "لم يتم العثور على دليل Workspace.",
|
||||
empty: "لا توجد تغييرات",
|
||||
loadError: "فشل تحميل الفروقات",
|
||||
|
||||
@@ -365,6 +365,13 @@ 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",
|
||||
@@ -374,8 +381,6 @@ export const en = {
|
||||
context: {
|
||||
size: "Size",
|
||||
modified: "Modified",
|
||||
copyPath: "Copy path",
|
||||
download: "Download",
|
||||
},
|
||||
actions: {
|
||||
back: "Back",
|
||||
@@ -763,6 +768,8 @@ export const en = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "Open Changes tab",
|
||||
closeChangesTab: "Close Changes tab",
|
||||
binaryFile: "Binary file",
|
||||
tooLarge: "Diff too large to display",
|
||||
unified: "Unified diff",
|
||||
@@ -1500,6 +1507,8 @@ 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",
|
||||
|
||||
@@ -370,6 +370,13 @@ 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",
|
||||
@@ -379,8 +386,6 @@ export const es: TranslationResources = {
|
||||
context: {
|
||||
size: "Tamaño",
|
||||
modified: "Modificado",
|
||||
copyPath: "Copiar ruta",
|
||||
download: "Descargar",
|
||||
},
|
||||
actions: {
|
||||
back: "Atrás",
|
||||
@@ -784,6 +789,8 @@ 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",
|
||||
@@ -1532,6 +1539,8 @@ 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",
|
||||
|
||||
@@ -370,6 +370,13 @@ 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",
|
||||
@@ -379,8 +386,6 @@ export const fr: TranslationResources = {
|
||||
context: {
|
||||
size: "Taille",
|
||||
modified: "Modifié",
|
||||
copyPath: "Copier le chemin",
|
||||
download: "Télécharger",
|
||||
},
|
||||
actions: {
|
||||
back: "Dos",
|
||||
@@ -783,6 +788,8 @@ 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é",
|
||||
@@ -1535,6 +1542,8 @@ 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",
|
||||
|
||||
@@ -370,6 +370,13 @@ export const ja: TranslationResources = {
|
||||
copyBranchName: "ブランチ名をコピー",
|
||||
copied: "コピーしました",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "ファイルを開く",
|
||||
copyPath: "パスをコピー",
|
||||
download: "ダウンロード",
|
||||
addToChat: "チャットに追加…",
|
||||
moreActions: "その他のアクション",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "名前",
|
||||
@@ -379,8 +386,6 @@ export const ja: TranslationResources = {
|
||||
context: {
|
||||
size: "サイズ",
|
||||
modified: "更新日時",
|
||||
copyPath: "パスをコピー",
|
||||
download: "ダウンロード",
|
||||
},
|
||||
actions: {
|
||||
back: "戻る",
|
||||
@@ -764,6 +769,8 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "変更タブを開く",
|
||||
closeChangesTab: "変更タブを閉じる",
|
||||
binaryFile: "バイナリファイル",
|
||||
tooLarge: "差分が大きすぎて表示できません",
|
||||
unified: "ユニファイド差分",
|
||||
@@ -1505,6 +1512,8 @@ export const ja: TranslationResources = {
|
||||
changesLabel: "変更",
|
||||
changesSubtitle: "作業ツリーの差分",
|
||||
commitSubtitle: "コミット差分",
|
||||
uncommittedSubtitle: "未コミットの変更",
|
||||
baseSubtitle: "{{baseRef}} との比較",
|
||||
directoryMissing: "ワークスペースディレクトリが見つかりません。",
|
||||
empty: "変更はありません",
|
||||
loadError: "差分の読み込みに失敗しました",
|
||||
|
||||
@@ -370,6 +370,13 @@ 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",
|
||||
@@ -379,8 +386,6 @@ export const ptBR: TranslationResources = {
|
||||
context: {
|
||||
size: "Tamanho",
|
||||
modified: "Modificado",
|
||||
copyPath: "Copiar caminho",
|
||||
download: "Baixar",
|
||||
},
|
||||
actions: {
|
||||
back: "Voltar",
|
||||
@@ -775,6 +780,8 @@ 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",
|
||||
@@ -1518,6 +1525,8 @@ 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",
|
||||
|
||||
@@ -369,6 +369,13 @@ export const ru: TranslationResources = {
|
||||
copyBranchName: "Копировать имя ветки",
|
||||
copied: "Скопировано",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "Открыть файл",
|
||||
copyPath: "Копировать путь",
|
||||
download: "Скачать",
|
||||
addToChat: "Добавить в чат…",
|
||||
moreActions: "Дополнительные действия",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "Имя",
|
||||
@@ -378,8 +385,6 @@ export const ru: TranslationResources = {
|
||||
context: {
|
||||
size: "Размер",
|
||||
modified: "Модифицированный",
|
||||
copyPath: "Копировать путь",
|
||||
download: "Скачать",
|
||||
},
|
||||
actions: {
|
||||
back: "Назад",
|
||||
@@ -775,6 +780,8 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "Открыть вкладку «Изменения»",
|
||||
closeChangesTab: "Закрыть вкладку «Изменения»",
|
||||
binaryFile: "Бинарный файл",
|
||||
tooLarge: "Разница слишком велика для отображения",
|
||||
unified: "Единый дифференциал",
|
||||
@@ -1523,6 +1530,8 @@ export const ru: TranslationResources = {
|
||||
changesLabel: "Изменения",
|
||||
changesSubtitle: "Различия рабочего дерева",
|
||||
commitSubtitle: "Различия коммита",
|
||||
uncommittedSubtitle: "Незафиксированные изменения",
|
||||
baseSubtitle: "Сравнение с {{baseRef}}",
|
||||
directoryMissing: "Каталог Workspace не найден.",
|
||||
empty: "Нет изменений",
|
||||
loadError: "Не удалось загрузить различия",
|
||||
|
||||
@@ -366,6 +366,13 @@ export const zhCN: TranslationResources = {
|
||||
copyBranchName: "复制分支名称",
|
||||
copied: "已复制",
|
||||
},
|
||||
fileActions: {
|
||||
openFile: "打开文件",
|
||||
copyPath: "复制路径",
|
||||
download: "下载",
|
||||
addToChat: "添加到聊天…",
|
||||
moreActions: "更多操作",
|
||||
},
|
||||
fileExplorer: {
|
||||
sort: {
|
||||
name: "名称",
|
||||
@@ -375,8 +382,6 @@ export const zhCN: TranslationResources = {
|
||||
context: {
|
||||
size: "大小",
|
||||
modified: "修改时间",
|
||||
copyPath: "复制路径",
|
||||
download: "下载",
|
||||
},
|
||||
actions: {
|
||||
back: "返回",
|
||||
@@ -745,6 +750,8 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
diff: {
|
||||
openChangesTab: "打开“更改”标签页",
|
||||
closeChangesTab: "关闭“更改”标签页",
|
||||
binaryFile: "二进制文件",
|
||||
tooLarge: "Diff 过大,无法显示",
|
||||
unified: "Unified diff",
|
||||
@@ -1470,6 +1477,8 @@ export const zhCN: TranslationResources = {
|
||||
changesLabel: "更改",
|
||||
changesSubtitle: "工作区差异",
|
||||
commitSubtitle: "提交差异",
|
||||
uncommittedSubtitle: "未提交的更改",
|
||||
baseSubtitle: "与 {{baseRef}} 比较",
|
||||
directoryMissing: "未找到 workspace 目录。",
|
||||
empty: "没有更改",
|
||||
loadError: "加载差异失败",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,85 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
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" };
|
||||
}
|
||||
@@ -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 "@/stores/workspace-tabs-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
useHideFinishedProviderSubagents,
|
||||
@@ -341,13 +341,14 @@ function useAgentPanelDescriptor(
|
||||
}
|
||||
|
||||
function AgentPanel() {
|
||||
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||
|
||||
return (
|
||||
<AgentPanelContent
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={target.agentId}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
@@ -481,11 +482,13 @@ type AgentLookupState =
|
||||
|
||||
function AgentPanelContent({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
isPaneFocused: boolean;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
@@ -535,6 +538,7 @@ function AgentPanelContent({
|
||||
return (
|
||||
<AgentPanelBody
|
||||
serverId={resolvedServerId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={resolvedAgentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={runtimeClient}
|
||||
@@ -547,6 +551,7 @@ function AgentPanelContent({
|
||||
|
||||
function AgentPanelBody({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
client,
|
||||
@@ -555,6 +560,7 @@ function AgentPanelBody({
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId?: string;
|
||||
isPaneFocused: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
@@ -585,6 +591,8 @@ function AgentPanelBody({
|
||||
);
|
||||
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
||||
const lookupAttemptTokenRef = useRef(0);
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent);
|
||||
|
||||
useEffect(() => {
|
||||
lookupAttemptTokenRef.current += 1;
|
||||
@@ -596,6 +604,9 @@ function AgentPanelBody({
|
||||
return;
|
||||
}
|
||||
if (agentState.id) {
|
||||
if (workspaceKey) {
|
||||
resolvePendingAgent(workspaceKey, agentId);
|
||||
}
|
||||
if (lookupState.tag !== "idle") {
|
||||
setLookupState({ tag: "idle" });
|
||||
}
|
||||
@@ -618,6 +629,9 @@ function AgentPanelBody({
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
if (workspaceKey) {
|
||||
resolvePendingAgent(workspaceKey, agentId);
|
||||
}
|
||||
setLookupState({
|
||||
tag: "not_found",
|
||||
message: `Agent not found: ${agentId}`,
|
||||
@@ -626,6 +640,9 @@ function AgentPanelBody({
|
||||
}
|
||||
|
||||
storeFetchedAgentDetail({ serverId, result });
|
||||
if (workspaceKey) {
|
||||
resolvePendingAgent(workspaceKey, agentId);
|
||||
}
|
||||
setLookupState({ tag: "idle" });
|
||||
return;
|
||||
})
|
||||
@@ -635,12 +652,25 @@ function AgentPanelBody({
|
||||
}
|
||||
const message = toErrorMessage(error);
|
||||
if (isNotFoundErrorMessage(message)) {
|
||||
if (workspaceKey) {
|
||||
resolvePendingAgent(workspaceKey, agentId);
|
||||
}
|
||||
setLookupState({ tag: "not_found", message });
|
||||
return;
|
||||
}
|
||||
setLookupState({ tag: "error", message });
|
||||
});
|
||||
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
|
||||
}, [
|
||||
agentId,
|
||||
agentState.id,
|
||||
client,
|
||||
hasSession,
|
||||
isConnected,
|
||||
lookupState.tag,
|
||||
resolvePendingAgent,
|
||||
serverId,
|
||||
workspaceKey,
|
||||
]);
|
||||
|
||||
if (lookupState.tag === "not_found") {
|
||||
return (
|
||||
@@ -894,6 +924,7 @@ function ChatAgentContent({
|
||||
routeKey: `${serverId}:${agentId ?? ""}`,
|
||||
input: {
|
||||
agent: agent ?? null,
|
||||
isArchived: agentState.archivedAt !== null,
|
||||
missingAgentState,
|
||||
isConnected,
|
||||
isArchivingCurrentAgent,
|
||||
@@ -947,6 +978,9 @@ function ChatAgentContent({
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentState.archivedAt) {
|
||||
return;
|
||||
}
|
||||
if (agentState.id && hasAppliedAuthoritativeHistory) {
|
||||
if (
|
||||
missingAgentState.kind === "resolving" ||
|
||||
@@ -1012,6 +1046,7 @@ function ChatAgentContent({
|
||||
});
|
||||
}, [
|
||||
agentState.id,
|
||||
agentState.archivedAt,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
agentId,
|
||||
client,
|
||||
@@ -1125,8 +1160,16 @@ 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, composerState } =
|
||||
rawAgentInputDraft;
|
||||
const {
|
||||
text,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
isHydrated,
|
||||
attachmentFocusRequestId,
|
||||
composerState,
|
||||
} = rawAgentInputDraft;
|
||||
const agentInputDraft = useMemo(
|
||||
(): AgentInputDraft => ({
|
||||
text,
|
||||
@@ -1135,9 +1178,19 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
setAttachments,
|
||||
clear,
|
||||
isHydrated,
|
||||
attachmentFocusRequestId,
|
||||
composerState,
|
||||
}),
|
||||
[text, setText, attachments, setAttachments, clear, isHydrated, composerState],
|
||||
[
|
||||
text,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
clear,
|
||||
isHydrated,
|
||||
attachmentFocusRequestId,
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const streamSection = (
|
||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||
@@ -1502,6 +1555,7 @@ function ActiveAgentComposer({
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
value={agentInputDraft.text}
|
||||
@@ -1513,6 +1567,7 @@ function ActiveAgentComposer({
|
||||
cwd={cwd}
|
||||
clearDraft={agentInputDraft.clear}
|
||||
autoFocus={isPaneFocused}
|
||||
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,409 @@
|
||||
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,6 +1,6 @@
|
||||
import React, { createContext, useContext, type ReactNode } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
|
||||
export interface PaneContextValue {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
export interface PanelIconProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||
import { browserPanelRegistration } from "@/panels/browser-panel";
|
||||
import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel";
|
||||
import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
@@ -22,5 +22,6 @@ export function ensurePanelsRegistered(): void {
|
||||
registerPanel(browserPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
registerPanel(commitDiffPanelRegistration);
|
||||
registerPanel(workingDiffPanelRegistration);
|
||||
panelsRegistered = true;
|
||||
}
|
||||
|
||||
@@ -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 "@/stores/workspace-tabs-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
|
||||
@@ -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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
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 },
|
||||
labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string },
|
||||
): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return labels.newAgent;
|
||||
@@ -458,6 +458,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -836,6 +839,7 @@ 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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
|
||||
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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
|
||||
@@ -74,14 +74,17 @@ 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 type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTab,
|
||||
type WorkspaceTabTarget,
|
||||
} from "@/workspace-tabs/model";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -291,11 +294,13 @@ interface WorkspaceScreenProps {
|
||||
workspaceId: string;
|
||||
isRouteFocused?: boolean;
|
||||
recoveryRequested?: boolean;
|
||||
recoveryAgentId?: string | null;
|
||||
}
|
||||
|
||||
type WorkspaceScreenContentProps = WorkspaceScreenProps & {
|
||||
isRouteFocused: boolean;
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -343,6 +348,7 @@ function getFallbackTabOptionLabel(
|
||||
terminal: string;
|
||||
browser: string;
|
||||
agent: string;
|
||||
changes: string;
|
||||
},
|
||||
): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
@@ -360,6 +366,9 @@ 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);
|
||||
}
|
||||
@@ -374,6 +383,7 @@ function getFallbackTabOptionDescription(
|
||||
agent: string;
|
||||
terminal: string;
|
||||
browser: string;
|
||||
changes: string;
|
||||
},
|
||||
): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
@@ -397,6 +407,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -669,6 +682,7 @@ 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],
|
||||
);
|
||||
@@ -912,6 +926,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
}: WorkspaceScreenProps) {
|
||||
const navigationFocused = useIsFocused();
|
||||
return (
|
||||
@@ -920,6 +935,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
workspaceId={workspaceId}
|
||||
isRouteFocused={isRouteFocused ?? navigationFocused}
|
||||
recoveryRequested={recoveryRequested ?? false}
|
||||
recoveryAgentId={recoveryAgentId ?? null}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1715,6 +1731,7 @@ function WorkspaceScreenContent({
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
}: WorkspaceScreenContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const _insets = useSafeAreaInsets();
|
||||
@@ -1791,6 +1808,7 @@ function WorkspaceScreenContent({
|
||||
const workspaceRecovery = useWorkspaceRecovery({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
agentId: recoveryAgentId,
|
||||
enabled: shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces,
|
||||
workspaceDescriptor,
|
||||
@@ -2508,6 +2526,7 @@ 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,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import {
|
||||
buildWorkspaceDesktopTabActions,
|
||||
buildWorkspaceTabMenuEntries,
|
||||
} from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
function createAgentTab(): WorkspaceTabDescriptor {
|
||||
@@ -253,6 +256,37 @@ 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,6 +144,9 @@ 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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
|
||||
describe("deriveWorkspaceTabModel", () => {
|
||||
it("keeps normalized tabs in stored order and preserves targets", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import {
|
||||
deriveWorkspacePaneState,
|
||||
type WorkspaceDerivedTab,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
export interface WorkspaceTabDescriptor {
|
||||
key: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types";
|
||||
import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import {
|
||||
garbageCollectAttachments,
|
||||
persistAttachmentFromDataUrl,
|
||||
@@ -39,16 +40,21 @@ 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[];
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreActions;
|
||||
interface DraftStoreRuntimeState {
|
||||
attachmentFocusRequestByDraftKey: Record<string, number>;
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
|
||||
|
||||
const draftGenerations = new Map<string, number>();
|
||||
let gcScheduled = false;
|
||||
const draftPersistStorage = createDraftPersistStorage(
|
||||
createJSONStorage<DraftStoreState>(() => AsyncStorage),
|
||||
@@ -237,6 +243,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
|
||||
getDraftInput: (draftKey) => {
|
||||
const record = get().drafts[draftKey];
|
||||
@@ -344,7 +351,32 @@ export const useDraftStore = create<DraftStore>()(
|
||||
return { drafts: nextDrafts };
|
||||
});
|
||||
|
||||
draftGenerations.delete(draftKey);
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
@@ -369,16 +401,6 @@ 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());
|
||||
},
|
||||
@@ -387,6 +409,7 @@ 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,6 +71,46 @@ 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,
|
||||
|
||||
@@ -3,7 +3,12 @@ import {
|
||||
type AttachmentMetadata,
|
||||
type UserComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import { ForgeSearchItemSchema, GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
|
||||
import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file";
|
||||
import {
|
||||
ForgeSearchItemSchema,
|
||||
GitHubSearchItemSchema,
|
||||
UploadedFileAttachmentSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
export const DRAFT_STORE_VERSION = 5;
|
||||
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -83,6 +88,12 @@ 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" &&
|
||||
@@ -113,6 +124,13 @@ 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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
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 "@/stores/workspace-tabs-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
|
||||
export interface PendingWorkspaceDraftSubmission {
|
||||
serverId: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user