mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c2447c7ca | ||
|
|
19107fceb7 |
@@ -1,8 +1,11 @@
|
||||
name: Deploy Relay
|
||||
|
||||
on:
|
||||
# Manual-only while relay.paseo.sh bridges traffic to the Fly deployment.
|
||||
# A release or main push must not redeploy the temporary Cloudflare bridge.
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/relay/**"
|
||||
- ".github/workflows/deploy-relay.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -24,6 +27,6 @@ jobs:
|
||||
run: npm run typecheck --workspace=@getpaseo/relay
|
||||
|
||||
- name: Deploy worker
|
||||
run: cd packages/relay && npx wrangler deploy
|
||||
run: cd packages/relay && npx wrangler deploy -c wrangler.staging.toml
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -1,47 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.107 - 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- Inspect provider-created subagents and their live conversations from the Subagents track ([#2013](https://github.com/getpaseo/paseo/pull/2013) by [@omercnet](https://github.com/omercnet))
|
||||
- Fork chats with every supported agent provider ([#2022](https://github.com/getpaseo/paseo/pull/2022))
|
||||
|
||||
### Improved
|
||||
|
||||
- Add projects directly from New Workspace when none are configured ([#2026](https://github.com/getpaseo/paseo/pull/2026))
|
||||
- New terminals open at the correct size immediately ([#2023](https://github.com/getpaseo/paseo/pull/2023) by [@cleiter](https://github.com/cleiter))
|
||||
- Sidebar footer actions now explain themselves with tooltips ([#2025](https://github.com/getpaseo/paseo/pull/2025))
|
||||
- Codex shell tool calls show only the command being run ([#2029](https://github.com/getpaseo/paseo/pull/2029))
|
||||
- Custom ACP providers keep file and terminal work in the agent environment by default ([#2024](https://github.com/getpaseo/paseo/pull/2024))
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Large tables no longer make iOS chats unresponsive
|
||||
- Chat controls remain clickable near the scroll-to-bottom button ([#2007](https://github.com/getpaseo/paseo/pull/2007))
|
||||
- Oversized tool output no longer slows or floods chat timelines ([#2020](https://github.com/getpaseo/paseo/pull/2020))
|
||||
- Cross-provider subagents can use providers without mode settings ([#2000](https://github.com/getpaseo/paseo/pull/2000) by [@githubbzxs](https://github.com/githubbzxs))
|
||||
- Pi's internal metadata tasks no longer clutter normal session history ([#1999](https://github.com/getpaseo/paseo/pull/1999) by [@githubbzxs](https://github.com/githubbzxs))
|
||||
- Pi chats remain usable after canceling extension commands ([#2019](https://github.com/getpaseo/paseo/pull/2019))
|
||||
|
||||
## 0.1.106 - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Approve Codex MCP permission requests in Paseo ([#2001](https://github.com/getpaseo/paseo/pull/2001))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduced mobile chat freezes and blank screens when switching workspaces while agents are streaming ([#1989](https://github.com/getpaseo/paseo/pull/1989))
|
||||
- OpenCode sessions start reliably instead of occasionally losing the first turn ([#2015](https://github.com/getpaseo/paseo/pull/2015) by [@mcowger](https://github.com/mcowger))
|
||||
- Switching between workspaces no longer flashes a white screen
|
||||
- Pi keeps your existing MCP tools and settings when Paseo adds its own ([#1990](https://github.com/getpaseo/paseo/pull/1990) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
## 0.1.105 - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
+2
-10
@@ -71,21 +71,13 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**:
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
|
||||
|
||||
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
|
||||
|
||||
```
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
```
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track.
|
||||
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no Paseo lifecycle controls and disappear only when the provider removes them or the parent session is discarded.
|
||||
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
|
||||
|
||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||
|
||||
|
||||
@@ -457,37 +457,6 @@ Paseo tools such as subagent creation come from the shared internal tool catalog
|
||||
}
|
||||
```
|
||||
|
||||
ACP agents execute filesystem and terminal operations in their own environment
|
||||
by default. To let a compliant agent delegate those operations to Paseo instead,
|
||||
enable the corresponding client capabilities:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"local-agent": {
|
||||
"extends": "acp",
|
||||
"label": "Local Agent",
|
||||
"command": ["local-agent", "acp"],
|
||||
"params": {
|
||||
"clientCapabilities": {
|
||||
"fs": {
|
||||
"readTextFile": true,
|
||||
"writeTextFile": true
|
||||
},
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only enable capabilities Paseo should execute. When the agent and Paseo run in
|
||||
different environments, configure equivalent absolute workspace paths before
|
||||
delegating filesystem or terminal operations to Paseo.
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||
|
||||
+1
-1
@@ -218,7 +218,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Archive workspace is confirmed only when its worktree backing reports uncommitted changes or unpushed commits; otherwise it archives immediately.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
@@ -96,18 +96,6 @@ Keep workspace identity and retention outside native-stack `getId` and
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
Use `ThemedStack` from `packages/app/src/navigation/themed-stack.tsx` for every
|
||||
Expo Router stack. React Navigation otherwise paints each native stack screen
|
||||
with its light default background. A screen-level wrapper can hide that surface
|
||||
while settled, but Android may expose it for one frame when navigation crosses
|
||||
from a nested stack to its parent stack. This is especially visible when an
|
||||
app-wide route such as `/new` opens from a dark workspace.
|
||||
|
||||
Do not read the active theme with `useUnistyles()` in a layout to build
|
||||
`screenOptions`. `ThemedStack` keeps that third-party prop theme-reactive through
|
||||
a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
## Regression Shape
|
||||
|
||||
Pure helper tests are useful but not enough. The failure mode here is native
|
||||
|
||||
@@ -4,7 +4,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI and app shortcuts always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. CLI/MCP **archive worktree** is a separate lower-level operation that archives every workspace on that worktree.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality (`deriveWorkspaceKind`, `packages/server/src/server/workspace-registry-model.ts:158`), not stored from a user choice. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`). Don't confuse with **Isolation** (the create-time intent).
|
||||
- **Isolation** — Create-time choice for a new workspace: reuse the existing checkout (**Local**) or cut a dedicated git worktree (**New worktree**). A transient setup input, also remembered as a create-form preference; it is not a workspace property. UI: "Isolation" control on the New Workspace screen. Code: `isolation` (`"local" | "worktree"`), `useWorkspaceIsolation` (`packages/app/src/screens/new-workspace-screen.tsx`); persisted as `FormPreferences.isolation` (`packages/app/src/create-agent-preferences/preferences.ts`). Distinct from **Workspace kind**, which is the git-derived property the intent produces (Local → `local_checkout` or `directory` by git-ness; New worktree → `worktree`). On the wire it is the create request's `source.kind` (`directory | worktree`, `packages/protocol/src/messages.ts:1693`).
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. Because that flag replaces the Pi global config layer, preserve the existing `<Pi agent dir>/mcp.json` in the generated file before overlaying injected servers. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
|
||||
@@ -52,8 +52,6 @@ This bumps the version across all workspaces, runs checks, publishes to npm, and
|
||||
|
||||
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
|
||||
|
||||
Relay deployment is manual-only while `relay.paseo.sh` bridges traffic to the Fly deployment. Releases and pushes to `main` do not deploy the Cloudflare relay worker. Deploy it explicitly with `gh workflow run deploy-relay.yml` only when the production bridge should change.
|
||||
|
||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||
|
||||
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
||||
|
||||
@@ -9,11 +9,6 @@ The invariant is:
|
||||
|
||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
|
||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
||||
tool payload.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
Client heartbeat reports presence:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-lYvP0RKgxshO68+bjkkUXX9YJtzgoGIp8kGdGFYOT+o=
|
||||
sha256-cU6qRXY9fnr80pllmqJts5w8+OiE6F99SRo2d9G0HDA=
|
||||
|
||||
Generated
+21
-21
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -35152,7 +35152,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36170,12 +36170,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.107",
|
||||
"@getpaseo/protocol": "0.1.107",
|
||||
"@getpaseo/server": "0.1.107",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/server": "0.1.105",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36421,10 +36421,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.107",
|
||||
"@getpaseo/relay": "0.1.107",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36435,7 +36435,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36678,7 +36678,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37574,7 +37574,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37806,7 +37806,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37819,7 +37819,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38037,15 +38037,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.107",
|
||||
"@getpaseo/highlight": "0.1.107",
|
||||
"@getpaseo/protocol": "0.1.107",
|
||||
"@getpaseo/relay": "0.1.107",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/highlight": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38582,7 +38582,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "./helpers/agent-stream";
|
||||
import {
|
||||
expectScrollStaysFixed,
|
||||
clickToolCallBesideScrollToBottomButton,
|
||||
readScrollMetrics,
|
||||
scrollAgentChatToBottom,
|
||||
scrollChatAwayFromBottom,
|
||||
@@ -206,37 +205,6 @@ test.describe("Agent stream UI", () => {
|
||||
await expectScrollStaysFixed(page, baseline);
|
||||
});
|
||||
|
||||
test("keeps tool calls clickable beside the scroll-to-bottom button", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "stream-scroll-button-hit-area-",
|
||||
title: "Scroll button hit area",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Stream enough content to exercise the scroll button hit area.",
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await waitForScrollableChat(page, {
|
||||
minScrollableDistance: SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE,
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const hitArea = await clickToolCallBesideScrollToBottomButton(page);
|
||||
|
||||
expect(hitArea).toEqual({
|
||||
outsideButton: true,
|
||||
toolCallReceivesPointer: true,
|
||||
withinButtonBand: true,
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
@@ -12,7 +11,7 @@ function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
@@ -22,6 +21,10 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro
|
||||
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
||||
await kebab.click();
|
||||
|
||||
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
|
||||
// user-confirmed archive proceeds deterministically.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
||||
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
||||
await archiveItem.click();
|
||||
@@ -156,21 +159,17 @@ test.describe("Project with no workspaces persists", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
await expect(workspaceRow).toBeVisible({
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await workspaceRow.click();
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
// The workspace row goes away, but its project parent stays and exposes a
|
||||
// child row for creating the next workspace.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
|
||||
@@ -124,111 +124,6 @@ export async function scrollChatAwayFromBottom(
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function clickToolCallBesideScrollToBottomButton(page: Page): Promise<{
|
||||
outsideButton: boolean;
|
||||
toolCallReceivesPointer: boolean;
|
||||
withinButtonBand: boolean;
|
||||
}> {
|
||||
await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
|
||||
const scrollToBottomButton = page.getByRole("button", { name: "Scroll to bottom" });
|
||||
await expect(scrollToBottomButton).toBeVisible();
|
||||
|
||||
const buttonBounds = await scrollToBottomButton.boundingBox();
|
||||
expect(buttonBounds, "Expected visible scroll-to-bottom button bounds").not.toBeNull();
|
||||
const visibleButtonBounds = buttonBounds!;
|
||||
|
||||
const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]');
|
||||
const toolCallBounds = await Promise.all(
|
||||
Array.from({ length: await toolCalls.count() }, async (_, index) => ({
|
||||
index,
|
||||
bounds: await toolCalls.nth(index).boundingBox(),
|
||||
})),
|
||||
);
|
||||
const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2;
|
||||
const candidate = toolCallBounds
|
||||
.filter(
|
||||
(entry): entry is { index: number; bounds: NonNullable<typeof entry.bounds> } =>
|
||||
entry.bounds !== null && entry.bounds.width > 0,
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) -
|
||||
Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY),
|
||||
)[0];
|
||||
expect(
|
||||
candidate,
|
||||
`Expected at least one rendered tool-call badge: ${JSON.stringify({
|
||||
buttonBounds,
|
||||
scrollMetrics: await readScrollMetrics(page),
|
||||
toolCallBounds,
|
||||
})}`,
|
||||
).toBeDefined();
|
||||
const visibleToolCall = candidate!;
|
||||
const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2;
|
||||
await getVisibleChatScroll(page).evaluate((scroll, deltaY) => {
|
||||
(scroll as HTMLElement).scrollTop += deltaY;
|
||||
}, initialToolCallCenterY - buttonCenterY);
|
||||
|
||||
const alignedToolCall = toolCalls.nth(visibleToolCall.index);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [currentButtonBounds, currentToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
if (!currentButtonBounds || !currentToolCallBounds) {
|
||||
return false;
|
||||
}
|
||||
const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2;
|
||||
return (
|
||||
toolCallCenterY >= currentButtonBounds.y &&
|
||||
toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([
|
||||
scrollToBottomButton.boundingBox(),
|
||||
alignedToolCall.boundingBox(),
|
||||
]);
|
||||
expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull();
|
||||
expect(
|
||||
visibleToolCallBounds,
|
||||
"Expected aligned tool-call badge to remain visible",
|
||||
).not.toBeNull();
|
||||
const finalButtonBounds = alignedButtonBounds!;
|
||||
const finalToolCallBounds = visibleToolCallBounds!;
|
||||
|
||||
const clickPoint = {
|
||||
x: finalToolCallBounds.x + 24,
|
||||
y: finalToolCallBounds.y + finalToolCallBounds.height / 2,
|
||||
};
|
||||
const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => {
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
return hit !== null && toolCall.contains(hit);
|
||||
}, clickPoint);
|
||||
const hitArea = {
|
||||
clickPoint,
|
||||
outsideButton:
|
||||
clickPoint.x < finalButtonBounds.x ||
|
||||
clickPoint.x > finalButtonBounds.x + finalButtonBounds.width,
|
||||
toolCallReceivesPointer,
|
||||
withinButtonBand:
|
||||
clickPoint.y >= finalButtonBounds.y &&
|
||||
clickPoint.y <= finalButtonBounds.y + finalButtonBounds.height,
|
||||
};
|
||||
await page.mouse.click(hitArea.clickPoint.x, hitArea.clickPoint.y);
|
||||
return {
|
||||
outsideButton: hitArea.outsideButton,
|
||||
toolCallReceivesPointer: hitArea.toolCallReceivesPointer,
|
||||
withinButtonBand: hitArea.withinButtonBand,
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectScrollStaysFixed(
|
||||
page: Page,
|
||||
baseline: ScrollMetrics,
|
||||
|
||||
@@ -154,10 +154,6 @@ export async function launchAgent(input: {
|
||||
provider: RewindFlowProvider;
|
||||
cwd: string;
|
||||
mode: "full-access";
|
||||
providerConfig?: {
|
||||
model?: string;
|
||||
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
|
||||
};
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||
@@ -184,7 +180,6 @@ export async function launchAgent(input: {
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
...input.providerConfig,
|
||||
cwd: input.cwd,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
|
||||
@@ -39,9 +39,10 @@ export async function clickArchiveWorkspaceMenuItem(
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean workspace archives with no prompt. Managed worktree backing may raise
|
||||
// a browser confirm for unsynced work, so accept it when present.
|
||||
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean worktree archives with no prompt; if the host reports unsynced work the app
|
||||
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
|
||||
// either way.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,6 @@ import {
|
||||
hasGithubAuth,
|
||||
} from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedSavedSettingsHosts } from "./helpers/settings";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
@@ -211,53 +209,6 @@ test.describe("New workspace flow", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("adds a project from the selected empty host", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
||||
const primaryServerId = getServerId();
|
||||
const emptyServerId = "empty-new-workspace-host";
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{
|
||||
serverId: primaryServerId,
|
||||
label: "Primary host",
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: emptyServerId,
|
||||
label: "Empty host",
|
||||
endpoint: "127.0.0.1:9",
|
||||
},
|
||||
]);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
|
||||
const projectTrigger = page.getByTestId("new-workspace-project-picker-trigger");
|
||||
await projectTrigger.click();
|
||||
await page.getByPlaceholder("Search projects").fill("no matching project");
|
||||
await expect(page.getByTestId("new-workspace-project-picker-add-project")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("host-picker-trigger").click();
|
||||
await page.getByTestId(`new-workspace-host-picker-option-${emptyServerId}`).click();
|
||||
await expect(projectTrigger).toContainText("Choose project");
|
||||
await projectTrigger.click();
|
||||
|
||||
const addProject = page.getByTestId("new-workspace-project-picker-add-project");
|
||||
await expect(addProject).toContainText("Add project");
|
||||
await expect(addProject).toContainText(/(?:⌘|Ctrl\+)O/);
|
||||
await addProject.click();
|
||||
|
||||
await expect(page.getByTestId("project-picker-input")).toBeVisible();
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowProvider,
|
||||
} from "./helpers/rewind-flow";
|
||||
import { openSubagentsTrack } from "./helpers/subagents";
|
||||
|
||||
interface ProviderSubagentCase {
|
||||
provider: RewindFlowProvider;
|
||||
sentinel: string;
|
||||
prompt: string;
|
||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||
}
|
||||
|
||||
const cases: ProviderSubagentCase[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
sentinel: "CLAUDE_CHILD_SENTINEL",
|
||||
providerConfig: { model: "opus" },
|
||||
prompt:
|
||||
"Use the Task tool exactly once with the Explore subagent. Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.",
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
sentinel: "CODEX_CHILD_SENTINEL",
|
||||
providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } },
|
||||
prompt:
|
||||
'Use collaboration.spawn_agent exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE.',
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||
prompt:
|
||||
"Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("real provider subagent timelines", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
for (const scenario of cases) {
|
||||
test(`${scenario.provider} exposes native child output from the subagent track`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await sendMessage(handle, scenario.prompt);
|
||||
await openSubagentsTrack(page);
|
||||
|
||||
const rows = page.locator('[data-testid^="subagents-track-row-"]');
|
||||
await expect(rows).toHaveCount(1, { timeout: 60_000 });
|
||||
await rows.first().click();
|
||||
|
||||
const panel = page.getByTestId("provider-subagent-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive shortcut", () => {
|
||||
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Shift+Backspace`);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
|
||||
return warning;
|
||||
}
|
||||
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
test.describe("Worktree archive risk warning", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -97,7 +97,7 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive with worktree backing", () => {
|
||||
test.describe("Worktree archive", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -33,7 +33,9 @@ test.describe("Workspace archive with worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
||||
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
@@ -47,51 +49,11 @@ test.describe("Workspace archive with worktree backing", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
||||
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const first = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `shared-archive-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(first.workspaceDirectory);
|
||||
|
||||
const siblingPayload = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: first.workspaceDirectory,
|
||||
},
|
||||
title: "Second workspace",
|
||||
});
|
||||
if (!siblingPayload.workspace) {
|
||||
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
|
||||
}
|
||||
const sibling = siblingPayload.workspace;
|
||||
expect(sibling.workspaceKind).toBe("worktree");
|
||||
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, first.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
|
||||
expect(existsSync(first.workspaceDirectory)).toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, sibling.id);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
|
||||
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -228,20 +228,13 @@ export interface AgentStreamViewHandle {
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
context: AgentScreenAgent;
|
||||
agent: AgentScreenAgent;
|
||||
streamItems: StreamItem[];
|
||||
streamHead?: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
toast?: ToastApi | null;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
readOnly?: boolean;
|
||||
historyPagination?: {
|
||||
hasOlder: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
onLoadOlder: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
@@ -313,16 +306,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
{
|
||||
agentId,
|
||||
serverId,
|
||||
context,
|
||||
agent,
|
||||
streamItems,
|
||||
streamHead: providedStreamHead,
|
||||
pendingPermissions,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
toast,
|
||||
onOpenWorkspaceFile,
|
||||
readOnly = false,
|
||||
historyPagination,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -347,37 +337,27 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
||||
const sessionStreamHead = useSessionStore((state) =>
|
||||
const streamHead = useSessionStore((state) =>
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||
);
|
||||
const streamHead = providedStreamHead ?? sessionStreamHead;
|
||||
const supportsAgentForkContext = useSessionStore(
|
||||
(state) =>
|
||||
!readOnly &&
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = context.cwd?.trim() || "";
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceId: agent.workspaceId,
|
||||
workspaceRoot,
|
||||
});
|
||||
const agentHistoryPagination = useLoadOlderAgentHistory({
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
|
||||
serverId: resolvedServerId,
|
||||
agentId,
|
||||
toast,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = historyPagination
|
||||
? {
|
||||
isLoadingOlder: historyPagination.isLoadingOlder,
|
||||
hasOlder: historyPagination.hasOlder,
|
||||
loadOlder: historyPagination.onLoadOlder,
|
||||
}
|
||||
: agentHistoryPagination;
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
// tracked in react-native-reanimated#8422.
|
||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||
@@ -399,7 +379,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
|
||||
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
@@ -422,10 +402,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.workspaceId) {
|
||||
if (agent.workspaceId) {
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId,
|
||||
workspaceId: agent.workspaceId,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
@@ -439,8 +419,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const checkout = {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
cwd: agent.cwd,
|
||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||
};
|
||||
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||
openFileExplorerForCheckout({
|
||||
@@ -464,7 +444,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const draftSetup = buildForkDraftSetup(context);
|
||||
const draftSetup = buildForkDraftSetup(agent);
|
||||
const prepareForkDraft = async () => {
|
||||
const draftId = generateDraftId();
|
||||
const payload = await client.buildAgentForkContext(
|
||||
@@ -486,7 +466,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
};
|
||||
|
||||
if (target === "tab") {
|
||||
const workspaceId = context.workspaceId;
|
||||
const workspaceId = agent.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new Error(t("message.actions.forkMissingWorkspace"));
|
||||
}
|
||||
@@ -501,7 +481,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const draftId = await prepareForkDraft();
|
||||
const sourceDirectory =
|
||||
context.projectPlacement?.checkout?.cwd?.trim() || context.cwd.trim() || undefined;
|
||||
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
|
||||
if (draftSetup) {
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId,
|
||||
@@ -513,8 +493,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
buildNewWorkspaceRoute({
|
||||
serverId: resolvedServerId,
|
||||
sourceDirectory,
|
||||
displayName: context.projectPlacement?.projectName,
|
||||
projectId: context.projectPlacement?.projectKey,
|
||||
displayName: agent.projectPlacement?.projectName,
|
||||
projectId: agent.projectPlacement?.projectKey,
|
||||
draftId,
|
||||
}),
|
||||
);
|
||||
@@ -540,24 +520,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
agentStatus: context.status,
|
||||
agentStatus: agent.status,
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [context.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
||||
}, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
|
||||
const streamLayout = useMemo(
|
||||
() =>
|
||||
layoutStream({
|
||||
strategy: streamRenderStrategy,
|
||||
agentStatus: context.status,
|
||||
agentStatus: agent.status,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
}),
|
||||
[
|
||||
context.status,
|
||||
agent.status,
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
@@ -610,14 +590,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
capabilities={context.capabilities}
|
||||
capabilities={agent.capabilities}
|
||||
client={client}
|
||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[context.capabilities, agentId, client, resolvedServerId],
|
||||
[agent.capabilities, agentId, client, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -688,7 +668,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
error={data.error}
|
||||
status={data.status}
|
||||
detail={data.detail}
|
||||
cwd={context.cwd}
|
||||
cwd={agent.cwd}
|
||||
metadata={data.metadata}
|
||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
@@ -710,7 +690,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
/>
|
||||
);
|
||||
},
|
||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
);
|
||||
|
||||
const renderStreamItemContent = useCallback(
|
||||
@@ -767,10 +747,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
onForkAssistantTurn: handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
|
||||
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -778,7 +758,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[pendingPermissions, agentId],
|
||||
);
|
||||
|
||||
const showRunningTurnFooter = context.status === "running";
|
||||
const showRunningTurnFooter = agent.status === "running";
|
||||
const pendingPermissionsNode = useMemo(
|
||||
() =>
|
||||
renderPendingPermissionsNode({
|
||||
@@ -795,12 +775,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
|
||||
onForkAssistantTurn={handleForkAssistantTurn}
|
||||
/>
|
||||
) : null,
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
showRunningTurnFooter,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
@@ -926,8 +905,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
})}
|
||||
</MessageOuterSpacingProvider>
|
||||
{!isNearBottom && (
|
||||
<View style={stylesheet.scrollToBottomContainer} pointerEvents="box-none">
|
||||
<Animated.View entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut}>
|
||||
<Animated.View
|
||||
style={stylesheet.scrollToBottomContainer}
|
||||
entering={scrollIndicatorFadeIn}
|
||||
exiting={scrollIndicatorFadeOut}
|
||||
>
|
||||
<View style={stylesheet.scrollToBottomInner}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
@@ -937,8 +920,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
>
|
||||
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</ToolCallSheetProvider>
|
||||
@@ -1021,17 +1004,6 @@ function bottomAnchorRouteRequestsEqual(
|
||||
);
|
||||
}
|
||||
|
||||
function historyPaginationPropsEqual(
|
||||
left: AgentStreamViewProps["historyPagination"],
|
||||
right: AgentStreamViewProps["historyPagination"],
|
||||
): boolean {
|
||||
return (
|
||||
left?.hasOlder === right?.hasOlder &&
|
||||
left?.isLoadingOlder === right?.isLoadingOlder &&
|
||||
left?.onLoadOlder === right?.onLoadOlder
|
||||
);
|
||||
}
|
||||
|
||||
function agentStreamViewPropsEqual(
|
||||
left: AgentStreamViewProps,
|
||||
right: AgentStreamViewProps,
|
||||
@@ -1039,9 +1011,8 @@ function agentStreamViewPropsEqual(
|
||||
const reasons: string[] = [];
|
||||
if (left.agentId !== right.agentId) reasons.push("agentId");
|
||||
if (left.serverId !== right.serverId) reasons.push("serverId");
|
||||
reasons.push(...collectAgentScreenAgentDiffs(left.context, right.context));
|
||||
reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent));
|
||||
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
|
||||
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
|
||||
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
|
||||
if (
|
||||
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
|
||||
@@ -1053,10 +1024,6 @@ function agentStreamViewPropsEqual(
|
||||
}
|
||||
if (left.toast !== right.toast) reasons.push("toast");
|
||||
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
|
||||
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
|
||||
if (!historyPaginationPropsEqual(left.historyPagination, right.historyPagination)) {
|
||||
reasons.push("historyPagination");
|
||||
}
|
||||
recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
|
||||
return reasons.length === 0;
|
||||
}
|
||||
@@ -1426,6 +1393,13 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
scrollToBottomInner: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
alignSelf: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
scrollToBottomButton: {
|
||||
width: 48,
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -785,15 +784,21 @@ function FaviconStatusSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROOT_STACK_SCREEN_OPTIONS = {
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
};
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
const stackScreenOptions = useMemo(
|
||||
() => ({
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
contentStyle: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
return (
|
||||
<ThemedStack screenOptions={ROOT_STACK_SCREEN_OPTIONS}>
|
||||
<Stack screenOptions={stackScreenOptions}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
@@ -810,7 +815,7 @@ function RootStack() {
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</ThemedStack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Redirect, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { HostRouteProvider } from "@/navigation/host-route-context";
|
||||
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
const HOST_STACK_SCREEN_OPTIONS = {
|
||||
@@ -34,14 +33,14 @@ function KnownHostRoute() {
|
||||
}
|
||||
|
||||
const stack = (
|
||||
<ThemedStack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="settings" />
|
||||
</ThemedStack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (!routeServerId) {
|
||||
|
||||
@@ -101,9 +101,9 @@ interface SidebarSharedProps {
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
newWorkspace: string;
|
||||
hosts: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
sessions: string;
|
||||
schedules: string;
|
||||
@@ -226,9 +226,9 @@ export const LeftSidebar = memo(function LeftSidebar() {
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
newWorkspace: t("sidebar.actions.newWorkspace"),
|
||||
hosts: t("sidebar.actions.hosts"),
|
||||
home: t("sidebar.actions.home"),
|
||||
settings: t("sidebar.actions.settings"),
|
||||
switchHost: t("sidebar.host.switchTitle"),
|
||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||
sessions: t("sidebar.sections.sessions"),
|
||||
schedules: t("sidebar.sections.schedules"),
|
||||
@@ -301,58 +301,47 @@ function FooterIconButton({
|
||||
buttonRef,
|
||||
onPress,
|
||||
testID,
|
||||
label,
|
||||
accessibilityLabel,
|
||||
icon: Icon,
|
||||
iconSize,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
label: string;
|
||||
accessibilityLabel: string;
|
||||
icon: typeof FolderPlus;
|
||||
iconSize?: number;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
buttonRef?: RefObject<View | null>;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize ?? theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize ?? theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHostPicker({
|
||||
theme,
|
||||
label,
|
||||
onAddHost,
|
||||
onOpenHostSettings,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
label: string;
|
||||
onAddHost: () => void;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
}) {
|
||||
@@ -391,7 +380,7 @@ function SidebarHostPicker({
|
||||
buttonRef={triggerRef}
|
||||
onPress={handleOpen}
|
||||
testID="sidebar-hosts-trigger"
|
||||
label={label}
|
||||
accessibilityLabel="Hosts"
|
||||
icon={Server}
|
||||
iconSize={theme.iconSize.sm}
|
||||
theme={theme}
|
||||
@@ -400,7 +389,22 @@ function SidebarHostPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function IconTooltipContent({
|
||||
function AddProjectTooltipContent({
|
||||
newAgentKeys,
|
||||
label,
|
||||
}: {
|
||||
newAgentKeys: ReturnType<typeof useShortcutKeys>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderIconTooltipContent({
|
||||
label,
|
||||
shortcutKeys,
|
||||
}: {
|
||||
@@ -485,47 +489,50 @@ function SidebarFooter({
|
||||
handleSettings: () => void;
|
||||
labels: {
|
||||
addProject: string;
|
||||
hosts: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
};
|
||||
handleAddHost: () => void;
|
||||
handleOpenHostSettings: (serverId: string) => void;
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
const settingsKeys = useShortcutKeys("toggle-settings");
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerIconRow}>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
accessibilityLabel={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
theme={theme}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<FooterIconButton
|
||||
onPress={handleHome}
|
||||
testID="sidebar-home"
|
||||
label={labels.home}
|
||||
accessibilityLabel={labels.home}
|
||||
icon={Home}
|
||||
theme={theme}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
label={labels.settings}
|
||||
accessibilityLabel={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
@@ -869,7 +876,7 @@ function WorkspacesSectionHeader() {
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<IconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
||||
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
@@ -879,7 +886,7 @@ function WorkspacesSectionHeader() {
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<IconTooltipContent label="Display preferences" />
|
||||
<HeaderIconTooltipContent label="Display preferences" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { iosMarkdownTextIsSelectable } from "./markdown-text-selection";
|
||||
|
||||
describe("markdown text selection", () => {
|
||||
it("uses plain text only for iOS table cells", () => {
|
||||
expect({
|
||||
tableCell: iosMarkdownTextIsSelectable("table-cell"),
|
||||
prose: iosMarkdownTextIsSelectable("prose"),
|
||||
}).toEqual({
|
||||
tableCell: false,
|
||||
prose: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
|
||||
export type MarkdownTextSurface = "prose" | "table-cell";
|
||||
|
||||
const MarkdownTextSurfaceContext = createContext<MarkdownTextSurface>("prose");
|
||||
|
||||
export function MarkdownTableCellText({ children }: { children: ReactNode }) {
|
||||
return <MarkdownTextSurfaceContext value="table-cell">{children}</MarkdownTextSurfaceContext>;
|
||||
}
|
||||
|
||||
export function useMarkdownTextSurface(): MarkdownTextSurface {
|
||||
return useContext(MarkdownTextSurfaceContext);
|
||||
}
|
||||
|
||||
export function iosMarkdownTextIsSelectable(surface: MarkdownTextSurface): boolean {
|
||||
return surface !== "table-cell";
|
||||
}
|
||||
@@ -1,18 +1,7 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { View, type StyleProp, type TextProps, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
|
||||
import {
|
||||
iosMarkdownTextIsSelectable,
|
||||
useMarkdownTextSurface,
|
||||
} from "@/components/markdown-text-selection";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
@@ -40,22 +29,6 @@ export function MarkdownTextSpan({
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
const plainStyle = useMemo(() => resolvePlainMarkdownTextStyle(style), [style]);
|
||||
const surface = useMarkdownTextSurface();
|
||||
|
||||
// Each selectable span creates a UIKit UITextView with a window-level tap recognizer.
|
||||
// A large table would create one per cell and make every app touch fan out across them.
|
||||
if (!iosMarkdownTextIsSelectable(surface)) {
|
||||
return (
|
||||
<Text
|
||||
selectable={false}
|
||||
style={plainStyle}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UITextView
|
||||
|
||||
@@ -25,7 +25,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
@@ -662,16 +661,6 @@ export function createSharedMarkdownRules(): RenderRules {
|
||||
</View>
|
||||
);
|
||||
},
|
||||
th: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownTableCellText key={node.key}>
|
||||
<View style={styles._VIEW_SAFE_th}>{children}</View>
|
||||
</MarkdownTableCellText>
|
||||
),
|
||||
td: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownTableCellText key={node.key}>
|
||||
<View style={styles._VIEW_SAFE_td}>{children}</View>
|
||||
</MarkdownTableCellText>
|
||||
),
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownTableCellText } from "@/components/markdown-text-selection";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
@@ -1826,16 +1825,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
</View>
|
||||
);
|
||||
},
|
||||
th: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownTableCellText key={node.key}>
|
||||
<View style={styles._VIEW_SAFE_th}>{children}</View>
|
||||
</MarkdownTableCellText>
|
||||
),
|
||||
td: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownTableCellText key={node.key}>
|
||||
<View style={styles._VIEW_SAFE_td}>{children}</View>
|
||||
</MarkdownTableCellText>
|
||||
),
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
@@ -111,7 +112,7 @@ import {
|
||||
} from "@/utils/sidebar-project-row-model";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import {
|
||||
getCurrentProjectRemoveReadiness,
|
||||
@@ -283,6 +284,16 @@ interface WorkspaceRowInnerProps {
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
function getWorkspaceArchiveStatus(
|
||||
isWorktree: boolean,
|
||||
archiveStatus: "idle" | "pending" | "success",
|
||||
isArchivingWorkspace: boolean,
|
||||
): "idle" | "pending" | "success" {
|
||||
if (isWorktree) return archiveStatus;
|
||||
if (isArchivingWorkspace) return "pending";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -1443,7 +1454,20 @@ function WorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
@@ -1455,6 +1479,7 @@ function WorkspaceRowWithMenu({
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
@@ -1521,7 +1546,7 @@ function WorkspaceRowWithMenu({
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -1533,8 +1558,8 @@ function WorkspaceRowWithMenu({
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.archive"],
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
@@ -1559,7 +1584,11 @@ function WorkspaceRowWithMenu({
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||
archiveStatus={getWorkspaceArchiveStatus(
|
||||
isWorktree,
|
||||
worktreeArchiveStatus,
|
||||
isHidingWorkspace,
|
||||
)}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
|
||||
@@ -23,9 +23,10 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -329,7 +330,20 @@ function StatusWorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
@@ -344,6 +358,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
@@ -393,7 +408,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -405,8 +420,8 @@ function StatusWorkspaceRowWithMenu({
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.archive"],
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
@@ -415,6 +430,13 @@ function StatusWorkspaceRowWithMenu({
|
||||
},
|
||||
});
|
||||
|
||||
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
|
||||
if (isWorktree) {
|
||||
computedArchiveStatus = worktreeArchiveStatus;
|
||||
} else if (isHidingWorkspace) {
|
||||
computedArchiveStatus = "pending";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusWorkspaceRowInner
|
||||
@@ -426,7 +448,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
onPress={onPress}
|
||||
isArchiving={isArchiving}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||
archiveStatus={computedArchiveStatus}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
|
||||
|
||||
@@ -11,13 +11,14 @@ import { DiffStat } from "@/components/diff-stat";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
@@ -65,7 +66,20 @@ export function SidebarWorkspaceRow({
|
||||
const toast = useToast();
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||
const isArchiving = workspace.archivingAt !== null || isHidingWorkspace;
|
||||
const workspaceDirectory = resolveWorkspaceDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const worktreeArchiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
workspaceDirectory
|
||||
? state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory,
|
||||
actionId: "archive-worktree",
|
||||
})
|
||||
: "idle",
|
||||
);
|
||||
const isWorktree = workspace.workspaceKind === "worktree";
|
||||
const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace;
|
||||
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
@@ -80,6 +94,7 @@ export function SidebarWorkspaceRow({
|
||||
const archiveController = useWorkspaceArchive({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
@@ -146,7 +161,7 @@ export function SidebarWorkspaceRow({
|
||||
[renameMutation],
|
||||
);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
@@ -158,8 +173,8 @@ export function SidebarWorkspaceRow({
|
||||
}, [clearAttention, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
actions: ["workspace.archive"],
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
@@ -168,6 +183,13 @@ export function SidebarWorkspaceRow({
|
||||
},
|
||||
});
|
||||
|
||||
let archiveStatus: "idle" | "pending" | "success" = "idle";
|
||||
if (isWorktree) {
|
||||
archiveStatus = worktreeArchiveStatus;
|
||||
} else if (isHidingWorkspace) {
|
||||
archiveStatus = "pending";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceRowBody
|
||||
@@ -183,7 +205,7 @@ export function SidebarWorkspaceRow({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
archiveLabel={t("sidebar.workspace.actions.archive")}
|
||||
archiveStatus={isArchiving ? "pending" : "idle"}
|
||||
archiveStatus={archiveStatus}
|
||||
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
|
||||
onArchive={handleArchive}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||
import { rememberTerminalViewportSize } from "@/terminal/runtime/terminal-size-cache";
|
||||
import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerStatus,
|
||||
@@ -636,9 +635,6 @@ export function TerminalPane({
|
||||
const normalizedCols = Math.floor(cols);
|
||||
const nextSize = { rows: normalizedRows, cols: normalizedCols };
|
||||
measuredTerminalSizeRef.current = nextSize;
|
||||
// Seed future terminals in this workspace with the current pane size so they are born at
|
||||
// the right size instead of the daemon's 80x24 default (see terminal-size-cache).
|
||||
rememberTerminalViewportSize({ serverId, cwd, size: nextSize });
|
||||
if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,8 +116,6 @@ export interface ComboboxProps {
|
||||
desktopFixedHeight?: number;
|
||||
/** Content rendered above the scroll area on desktop (sticky header). */
|
||||
stickyHeader?: ReactNode;
|
||||
/** Content rendered below the scroll area. */
|
||||
footer?: ReactNode;
|
||||
/** When true, selecting an option does not close the picker (multi-select mode). */
|
||||
keepOpenOnSelect?: boolean;
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
@@ -930,7 +928,6 @@ interface MobileBodyProps {
|
||||
header: SheetHeader | undefined;
|
||||
onClose: () => void;
|
||||
stickyHeader: ReactNode;
|
||||
footer: ReactNode;
|
||||
searchable: boolean;
|
||||
hasChildren: boolean;
|
||||
mobileChildrenScrollEnabled: boolean;
|
||||
@@ -1029,7 +1026,6 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -1043,7 +1039,6 @@ interface DesktopBodyProps {
|
||||
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
|
||||
header: SheetHeader | undefined;
|
||||
stickyHeader: ReactNode;
|
||||
footer: ReactNode;
|
||||
searchable: boolean;
|
||||
searchPlaceholder: string;
|
||||
searchQuery: string;
|
||||
@@ -1197,7 +1192,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
renderOption={props.renderOption}
|
||||
/>
|
||||
)}
|
||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Modal>
|
||||
@@ -1230,7 +1224,6 @@ export function Combobox({
|
||||
desktopMinWidth,
|
||||
desktopFixedHeight,
|
||||
stickyHeader,
|
||||
footer,
|
||||
keepOpenOnSelect = false,
|
||||
anchorRef,
|
||||
children,
|
||||
@@ -1511,7 +1504,6 @@ export function Combobox({
|
||||
header={header}
|
||||
onClose={handleClose}
|
||||
stickyHeader={stickyHeader}
|
||||
footer={footer}
|
||||
searchable={searchable}
|
||||
hasChildren={hasChildren}
|
||||
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
|
||||
@@ -1545,7 +1537,6 @@ export function Combobox({
|
||||
handleDesktopContentLayout={handleDesktopContentLayout}
|
||||
header={header}
|
||||
stickyHeader={stickyHeader}
|
||||
footer={footer}
|
||||
searchable={searchable}
|
||||
searchPlaceholder={effectiveSearchPlaceholder}
|
||||
searchQuery={searchQuery}
|
||||
@@ -1659,10 +1650,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[2],
|
||||
|
||||
@@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
agent={draftAgent}
|
||||
streamItems={optimisticStreamItems}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
|
||||
@@ -78,7 +78,6 @@ import {
|
||||
applyLegacyDaemonWorkspaceOwnership,
|
||||
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
|
||||
} from "@/workspace/legacy-daemon-workspaces";
|
||||
import { useProviderSubagentStore } from "@/subagents/provider-store";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -1350,11 +1349,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
applyTimelineResponse(message.payload);
|
||||
});
|
||||
|
||||
const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => {
|
||||
if (message.type !== "agent.provider_subagents.update") return;
|
||||
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
||||
});
|
||||
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
@@ -1756,7 +1750,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStream();
|
||||
unsubAgentTimeline();
|
||||
unsubProviderSubagentUpdate();
|
||||
unsubWorkspaceUpdate();
|
||||
unsubScriptStatusUpdate();
|
||||
unsubCheckoutStatusUpdate();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
interface PendingWorkspaceArchive {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}
|
||||
|
||||
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
|
||||
@@ -14,6 +15,7 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
|
||||
export function markWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
@@ -24,6 +26,7 @@ export function markWorkspaceArchivePending(input: {
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
|
||||
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
|
||||
workspaceId,
|
||||
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
|
||||
});
|
||||
pendingWorkspaceArchivesByServer.set(serverId, archives);
|
||||
}
|
||||
@@ -51,6 +54,7 @@ export function clearWorkspaceArchivePending(input: {
|
||||
export function isWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceDirectory?: string | null;
|
||||
}): boolean {
|
||||
const serverId = input.serverId.trim();
|
||||
if (!serverId) {
|
||||
@@ -63,7 +67,21 @@ export function isWorkspaceArchivePending(input: {
|
||||
}
|
||||
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
return Boolean(workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId })));
|
||||
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
||||
if (!workspaceDirectory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const archive of archives.values()) {
|
||||
if (archive.workspaceDirectory === workspaceDirectory) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
@@ -73,5 +91,6 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,10 +68,10 @@ const CATALOG_DATA = [
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.119.3",
|
||||
version: "2.118.2",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.119.3", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.118.2", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codewhale",
|
||||
@@ -140,10 +140,10 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.2.27",
|
||||
version: "0.2.26",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.27", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.26", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
@@ -159,10 +159,10 @@ const CATALOG_DATA = [
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.170.0",
|
||||
version: "0.169.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.170.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.169.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -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.7",
|
||||
version: "0.9.5",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.7", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.5", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.0.43",
|
||||
version: "1.0.41",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.43", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.41", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.19.9",
|
||||
version: "0.19.8",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.9", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.8", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
@@ -48,7 +48,7 @@ function GitActionMenuItem({
|
||||
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
|
||||
const trailing = useMemo(
|
||||
() =>
|
||||
action.id === "archive-workspace" && archiveShortcutKeys ? (
|
||||
action.id === "archive-worktree" && archiveShortcutKeys ? (
|
||||
<Shortcut chord={archiveShortcutKeys} />
|
||||
) : undefined,
|
||||
[action.id, archiveShortcutKeys],
|
||||
@@ -58,7 +58,7 @@ function GitActionMenuItem({
|
||||
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
testID={
|
||||
action.id === "archive-workspace"
|
||||
action.id === "archive-worktree"
|
||||
? "workspace-archive-action"
|
||||
: `changes-menu-${action.id}`
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
|
||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||
if (action.status === "pending") return action.pendingLabel;
|
||||
|
||||
@@ -2,10 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
__resetCheckoutGitActionsStoreForTests,
|
||||
isLocalWorktreeArchivePending,
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/git/actions-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
@@ -25,13 +31,33 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">) {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project",
|
||||
projectRootPath: input.projectRootPath ?? "/tmp/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/tmp/repo/worktrees/feature",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? input.id,
|
||||
status: input.status ?? "done",
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
} satisfies WorkspaceDescriptor;
|
||||
}
|
||||
|
||||
describe("checkout-git-actions-store", () => {
|
||||
const serverId = "server-1";
|
||||
const cwd = "/tmp/repo/worktrees/feature";
|
||||
const workspaceId = "ws-feature";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -39,6 +65,7 @@ describe("checkout-git-actions-store", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
appQueryClient.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
@@ -267,4 +294,118 @@ describe("checkout-git-actions-store", () => {
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(
|
||||
["sidebarPaseoWorktreeList", serverId, "/tmp"],
|
||||
[{ worktreePath: cwd }, { worktreePath: "/tmp/other" }],
|
||||
);
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
||||
{ worktreePath: "/tmp/other" },
|
||||
]);
|
||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("archives on the server even when its workspace cannot be resolved", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(async () => ({})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
// The server archive is keyed by worktreePath and must run regardless.
|
||||
expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd });
|
||||
// The optimistic client-side mark is never keyed by the path.
|
||||
expect(isWorkspaceArchivePending({ serverId, workspaceId: cwd })).toBe(false);
|
||||
});
|
||||
|
||||
it("restores an optimistically hidden worktree when archive fails", async () => {
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }];
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }),
|
||||
).rejects.toThrow("archive failed");
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
|
||||
featureWorkspace,
|
||||
);
|
||||
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual(
|
||||
listSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports local archive pending only while the archive action is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
||||
};
|
||||
const featureWorkspace = workspace({
|
||||
id: workspaceId,
|
||||
name: "feature",
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(serverId, new Map([[workspaceId, featureWorkspace]]));
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
|
||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
||||
|
||||
deferred.resolve({});
|
||||
await archive;
|
||||
|
||||
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { create } from "zustand";
|
||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -24,7 +36,8 @@ export type CheckoutGitAsyncActionId =
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base";
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
|
||||
type CheckoutKey = string;
|
||||
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
||||
@@ -76,6 +89,119 @@ function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
|
||||
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
|
||||
}
|
||||
|
||||
function invalidateWorktreeList() {
|
||||
void appQueryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
||||
});
|
||||
void appQueryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
|
||||
});
|
||||
}
|
||||
|
||||
function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const worktreePath = input.worktreePath.trim();
|
||||
if (!serverId || !worktreePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removeFromList = (current: unknown) => {
|
||||
if (!Array.isArray(current)) {
|
||||
return current;
|
||||
}
|
||||
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
|
||||
return filtered.length === current.length ? current : filtered;
|
||||
};
|
||||
|
||||
appQueryClient.setQueriesData(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === "paseoWorktreeList" &&
|
||||
query.queryKey[1] === serverId,
|
||||
},
|
||||
removeFromList,
|
||||
);
|
||||
|
||||
appQueryClient.setQueriesData(
|
||||
{
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) &&
|
||||
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
|
||||
query.queryKey[1] === serverId,
|
||||
},
|
||||
removeFromList,
|
||||
);
|
||||
}
|
||||
|
||||
interface WorktreeArchiveSnapshot {
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
worktreeLists: Array<[QueryKey, unknown]>;
|
||||
}
|
||||
|
||||
function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): boolean {
|
||||
return (
|
||||
Array.isArray(input.queryKey) &&
|
||||
(input.queryKey[0] === "paseoWorktreeList" ||
|
||||
input.queryKey[0] === "sidebarPaseoWorktreeList") &&
|
||||
input.queryKey[1] === input.serverId
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotWorktreeArchiveState(input: {
|
||||
serverId: string;
|
||||
workspaceId: string | undefined;
|
||||
}): WorktreeArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
|
||||
const workspaceKey = input.workspaceId
|
||||
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId })
|
||||
: null;
|
||||
return {
|
||||
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
||||
worktreeLists: appQueryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function removeWorktreeFromSessionStore(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
useSessionStore.getState().removeWorkspace(serverId, workspaceId);
|
||||
}
|
||||
|
||||
function restoreWorktreeArchiveState(input: {
|
||||
serverId: string;
|
||||
snapshot: WorktreeArchiveSnapshot;
|
||||
}): void {
|
||||
if (input.snapshot.workspace) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]);
|
||||
}
|
||||
|
||||
for (const [queryKey, data] of input.snapshot.worktreeLists) {
|
||||
appQueryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = input.workspaceId.trim();
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (workspaceKey) {
|
||||
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
|
||||
}
|
||||
useWorkspaceTabsStore.getState().purgeWorkspace({ serverId, workspaceId });
|
||||
}
|
||||
|
||||
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -83,6 +209,16 @@ function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): stri
|
||||
return `${key}::${actionId}`;
|
||||
}
|
||||
|
||||
export function isLocalWorktreeArchivePending(input: { serverId: string; cwd: string }): boolean {
|
||||
return (
|
||||
useCheckoutGitActionsStore.getState().getStatus({
|
||||
serverId: input.serverId,
|
||||
cwd: input.cwd,
|
||||
actionId: "archive-worktree",
|
||||
}) === "pending"
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckoutGitActionsStoreState {
|
||||
statusByCheckout: Record<CheckoutKey, StatusMap>;
|
||||
|
||||
@@ -111,6 +247,12 @@ interface CheckoutGitActionsStoreState {
|
||||
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
archiveWorktree: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
worktreePath: string;
|
||||
workspaceId?: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
async function runCheckoutAction({
|
||||
@@ -348,6 +490,50 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "archive-worktree",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId });
|
||||
// The server archive is keyed by worktreePath and must always run. The
|
||||
// optimistic client-side updates are keyed by workspace id, so they only
|
||||
// apply when the caller passes the workspace id and it resolves in the
|
||||
// local store.
|
||||
const workspace = snapshot.workspace;
|
||||
if (workspace) {
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
||||
try {
|
||||
const payload = await client.archivePaseoWorktree({
|
||||
worktreePath,
|
||||
...(workspaceId !== undefined ? { workspaceId } : {}),
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (workspace) {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
restoreWorktreeArchiveState({ serverId, snapshot });
|
||||
throw error;
|
||||
}
|
||||
invalidateWorktreeList();
|
||||
if (workspace) {
|
||||
purgeArchivedWorkspaceState({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
export function __resetCheckoutGitActionsStoreForTests() {
|
||||
|
||||
@@ -122,7 +122,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"archive-workspace": {
|
||||
"archive-worktree": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
@@ -140,13 +140,7 @@ describe("git-actions-policy", () => {
|
||||
it("shows only remote sync actions on the base branch", () => {
|
||||
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||
|
||||
expect(actions.primary).toBeNull();
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"archive-workspace",
|
||||
]);
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push", "pull-and-push"]);
|
||||
});
|
||||
|
||||
it("prioritizes pull when the branch is behind origin", () => {
|
||||
@@ -278,7 +272,6 @@ describe("git-actions-policy", () => {
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
"archive-workspace",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||
@@ -359,33 +352,12 @@ describe("git-actions-policy", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hides Git actions for a non-Git workspace", () => {
|
||||
const directory = buildGitActions(createInput({ isGit: false }));
|
||||
it("only shows archive worktree for paseo worktrees", () => {
|
||||
const hidden = buildGitActions(createInput());
|
||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||
|
||||
expect(directory).toEqual({ primary: null, secondary: [], menu: [] });
|
||||
});
|
||||
|
||||
it("offers archive workspace for Git checkouts and worktrees", () => {
|
||||
const localCheckout = buildGitActions(createInput({ hasUncommittedChanges: true }));
|
||||
const worktree = buildGitActions(
|
||||
createInput({ hasUncommittedChanges: true, isPaseoOwnedWorktree: true }),
|
||||
);
|
||||
|
||||
expect(localCheckout.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||
expect(worktree.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote archive to primary for an idle regular Git checkout", () => {
|
||||
const actions = buildGitActions(createInput());
|
||||
|
||||
expect(actions.primary).toBeNull();
|
||||
expect(actions.secondary.some((action) => action.id === "archive-workspace")).toBe(true);
|
||||
});
|
||||
|
||||
it("still promotes archive as primary for an idle Paseo-owned worktree", () => {
|
||||
const actions = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||
|
||||
expect(actions.primary).toMatchObject({ id: "archive-workspace" });
|
||||
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
|
||||
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
|
||||
@@ -569,7 +541,6 @@ describe("git-actions-policy", () => {
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -746,7 +717,6 @@ describe("git-actions-policy", () => {
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -787,7 +757,6 @@ describe("git-actions-policy", () => {
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"enable-pr-auto-merge-squash",
|
||||
"archive-workspace",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) =>
|
||||
@@ -930,7 +899,6 @@ describe("git-actions-policy", () => {
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-merge",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -984,7 +952,7 @@ describe("git-actions-policy", () => {
|
||||
.filter((action) => !action.startsGroup)
|
||||
.map((action) => action.id);
|
||||
|
||||
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-workspace"]);
|
||||
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
|
||||
expect(nonGroupStarters).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
|
||||
@@ -23,7 +23,7 @@ export type GitActionId =
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-workspace";
|
||||
| "archive-worktree";
|
||||
|
||||
export interface GitAction {
|
||||
id: GitActionId;
|
||||
@@ -282,16 +282,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
handler: input.runtime["merge-from-base"].handler,
|
||||
});
|
||||
|
||||
allActions.set("archive-workspace", {
|
||||
id: "archive-workspace",
|
||||
allActions.set("archive-worktree", {
|
||||
id: "archive-worktree",
|
||||
label: i18n.t("workspace.git.actions.archive.label"),
|
||||
pendingLabel: i18n.t("workspace.git.actions.archive.pending"),
|
||||
successLabel: i18n.t("workspace.git.actions.archive.success"),
|
||||
disabled: input.runtime["archive-workspace"].disabled,
|
||||
status: input.runtime["archive-workspace"].status,
|
||||
icon: input.runtime["archive-workspace"].icon,
|
||||
disabled: input.runtime["archive-worktree"].disabled,
|
||||
status: input.runtime["archive-worktree"].status,
|
||||
unavailableMessage:
|
||||
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
|
||||
? undefined
|
||||
: i18n.t("workspace.git.actions.unavailable.archiveNotWorktree"),
|
||||
icon: input.runtime["archive-worktree"].icon,
|
||||
startsGroup: true,
|
||||
handler: input.runtime["archive-workspace"].handler,
|
||||
handler: input.runtime["archive-worktree"].handler,
|
||||
});
|
||||
|
||||
const primaryActionId = getPrimaryActionId(input);
|
||||
@@ -301,20 +305,20 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
if (!input.isOnBaseBranch) {
|
||||
secondaryIds.push(...getFeatureActionIds(input));
|
||||
}
|
||||
secondaryIds.push("archive-workspace");
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
secondaryIds.push("archive-worktree");
|
||||
}
|
||||
|
||||
return {
|
||||
primary,
|
||||
secondary: secondaryIds
|
||||
.filter((id) => id !== "archive-workspace" || primaryActionId !== "archive-workspace")
|
||||
.map((id) => allActions.get(id)!),
|
||||
secondary: secondaryIds.map((id) => allActions.get(id)!),
|
||||
menu: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shouldPromoteArchive) {
|
||||
return "archive-workspace";
|
||||
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
|
||||
return "archive-worktree";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "commit";
|
||||
@@ -346,13 +350,6 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||
return "pr";
|
||||
}
|
||||
|
||||
// Only Paseo-owned worktrees get Archive as a fallback primary action.
|
||||
// Regular Git checkouts should not show the destructive archive CTA by default.
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
return "archive-workspace";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
useActiveWorkspaceSelection,
|
||||
type ActiveWorkspaceSelection,
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -98,12 +97,15 @@ function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCom
|
||||
}
|
||||
|
||||
function computeShouldPromoteArchive(input: {
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
postShipArchiveSuggested: boolean;
|
||||
isMergedPullRequest: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
!input.hasUncommittedChanges && (input.postShipArchiveSuggested || input.isMergedPullRequest)
|
||||
input.isPaseoOwnedWorktree &&
|
||||
!input.hasUncommittedChanges &&
|
||||
(input.postShipArchiveSuggested || input.isMergedPullRequest)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,6 +131,7 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
|
||||
shouldPromoteArchive: computeShouldPromoteArchive({
|
||||
isPaseoOwnedWorktree,
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isMergedPullRequest,
|
||||
@@ -170,52 +173,6 @@ interface UseWorkspaceScreenArchiveControllerInput {
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
function resolveArchiveWorkspaceDescriptor(input: {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | undefined;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): WorkspaceDescriptor | null {
|
||||
const activeWorkspaceKey = input.activeWorkspaceSelection
|
||||
? resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces: input.workspaces,
|
||||
workspaceId: input.activeWorkspaceSelection.workspaceId,
|
||||
})
|
||||
: null;
|
||||
if (activeWorkspaceKey) {
|
||||
return input.workspaces?.get(activeWorkspaceKey) ?? null;
|
||||
}
|
||||
if (!input.workspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of input.workspaces?.values() ?? []) {
|
||||
if (candidate.workspaceDirectory === input.workspaceDirectory) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWorkspaceArchiveRisk(
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
gitStatus: CheckoutStatusPayload | null,
|
||||
): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } {
|
||||
return {
|
||||
isDirty: gitStatus?.isDirty ?? workspace?.gitRuntime?.isDirty,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? workspace?.gitRuntime?.aheadOfOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
function canArchiveWorkspace(
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
risk: ReturnType<typeof resolveWorkspaceArchiveRisk>,
|
||||
): boolean {
|
||||
return (
|
||||
workspace !== null &&
|
||||
(workspace.workspaceKind !== "worktree" ||
|
||||
(risk.isDirty !== undefined && risk.aheadOfOrigin !== undefined))
|
||||
);
|
||||
}
|
||||
|
||||
function useWorkspaceScreenArchiveController({
|
||||
serverId,
|
||||
activeWorkspaceSelection,
|
||||
@@ -225,28 +182,28 @@ function useWorkspaceScreenArchiveController({
|
||||
t,
|
||||
}: UseWorkspaceScreenArchiveControllerInput) {
|
||||
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const workspaceDescriptor = useMemo(
|
||||
() =>
|
||||
resolveArchiveWorkspaceDescriptor({
|
||||
workspaces: sessionWorkspaces,
|
||||
activeWorkspaceSelection,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
|
||||
);
|
||||
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
|
||||
const archiveWorkspaceRecord = useMemo(() => {
|
||||
if (!workspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
for (const candidate of sessionWorkspaces?.values() ?? []) {
|
||||
if (candidate.workspaceDirectory === workspaceDirectory) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [sessionWorkspaces, workspaceDirectory]);
|
||||
|
||||
const controller = useWorkspaceArchive({
|
||||
return useWorkspaceArchive({
|
||||
serverId,
|
||||
workspaceId: workspaceDescriptor?.id ?? "",
|
||||
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
|
||||
name: workspaceDescriptor?.name ?? branchLabel,
|
||||
isDirty: archiveRisk.isDirty,
|
||||
aheadOfOrigin: archiveRisk.aheadOfOrigin,
|
||||
diffStat: workspaceDescriptor?.diffStat ?? null,
|
||||
workspaceId: activeWorkspaceSelection?.workspaceId ?? archiveWorkspaceRecord?.id ?? "",
|
||||
workspaceDirectory,
|
||||
workspaceKind: gitStatus?.isPaseoOwnedWorktree ? "worktree" : "local_checkout",
|
||||
name: archiveWorkspaceRecord?.name ?? branchLabel,
|
||||
isDirty: gitStatus?.isDirty,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin,
|
||||
diffStat: archiveWorkspaceRecord?.diffStat ?? null,
|
||||
warningLabels: getWorktreeArchiveWarningLabels(t),
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
onArchiveStarted: () => {
|
||||
if (!activeWorkspaceSelection) {
|
||||
return;
|
||||
@@ -258,12 +215,6 @@ function useWorkspaceScreenArchiveController({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...controller,
|
||||
isArchiving: workspaceDescriptor?.archivingAt != null || isHidingWorkspace,
|
||||
canArchive: canArchiveWorkspace(workspaceDescriptor, archiveRisk),
|
||||
};
|
||||
}
|
||||
|
||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||
@@ -391,6 +342,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
|
||||
);
|
||||
const archiveStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
|
||||
);
|
||||
|
||||
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
|
||||
const runPull = useCheckoutGitActionsStore((s) => s.pull);
|
||||
@@ -579,7 +533,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
t,
|
||||
});
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
archiveController.archive();
|
||||
}, [archiveController]);
|
||||
|
||||
@@ -724,11 +678,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
icon: icons.mergeFromBase,
|
||||
handler: handleMergeFromBase,
|
||||
},
|
||||
"archive-workspace": {
|
||||
disabled: !archiveController.canArchive || archiveController.isArchiving,
|
||||
status: archiveController.isArchiving ? "pending" : "idle",
|
||||
"archive-worktree": {
|
||||
disabled: isActionDisabled(actionsDisabled, archiveStatus),
|
||||
status: archiveStatus,
|
||||
icon: icons.archive,
|
||||
handler: handleArchiveWorkspace,
|
||||
handler: handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -769,8 +723,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveController.canArchive,
|
||||
archiveController.isArchiving,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
@@ -781,7 +734,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorkspace,
|
||||
handleArchiveWorktree,
|
||||
icons,
|
||||
baseRef,
|
||||
],
|
||||
@@ -937,7 +890,7 @@ function getTranslatedGitActionLabels(
|
||||
pendingLabel: t("workspace.git.actions.mergeFromBase.pending"),
|
||||
successLabel: t("workspace.git.actions.mergeFromBase.success"),
|
||||
};
|
||||
case "archive-workspace":
|
||||
case "archive-worktree":
|
||||
return {
|
||||
label: t("workspace.git.actions.archive.label"),
|
||||
pendingLabel: t("workspace.git.actions.archive.pending"),
|
||||
@@ -992,6 +945,8 @@ function translateGitActionUnavailableMessage(
|
||||
"workspace.git.actions.unavailable.updateNoBase",
|
||||
"Update isn't available while you have local changes so commit or stash them first":
|
||||
"workspace.git.actions.unavailable.updateDirty",
|
||||
"Archive isn't available here because this workspace was not created as a Paseo worktree":
|
||||
"workspace.git.actions.unavailable.archiveNotWorktree",
|
||||
"Merge PR isn't available right now because GitHub isn't connected":
|
||||
"workspace.git.actions.unavailable.mergePrNoGithub",
|
||||
"Merge PR isn't available because there isn't a pull request yet":
|
||||
@@ -1024,7 +979,7 @@ function getWorktreeArchiveWarningLabels(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): WorktreeArchiveWarningLabels {
|
||||
return {
|
||||
title: (workspaceName) => t("workspace.git.actions.archiveWarning.title", { workspaceName }),
|
||||
title: (worktreeName) => t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
||||
confirm: t("workspace.git.actions.archiveWarning.confirm"),
|
||||
cancel: t("workspace.git.actions.archiveWarning.cancel"),
|
||||
uncommittedChanges: t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface WorkspaceActionsProps {
|
||||
interface WorkspaceGitActionsProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
hideLabels?: boolean;
|
||||
@@ -47,12 +47,16 @@ const ICONS = {
|
||||
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
|
||||
};
|
||||
|
||||
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||
const { gitActions } = useGitActions({
|
||||
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
|
||||
const { gitActions, isGit } = useGitActions({
|
||||
serverId,
|
||||
cwd,
|
||||
icons: ICONS,
|
||||
});
|
||||
|
||||
if (!isGit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
toWorktreeArchiveRisk,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
|
||||
describe("workspace archive warning for worktree backing", () => {
|
||||
describe("worktree archive warning", () => {
|
||||
it("does not require a confirmation for clean and pushed worktrees", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveConfirmationMessage({
|
||||
workspaceName: "feature",
|
||||
worktreeName: "feature",
|
||||
isDirty: false,
|
||||
aheadOfOrigin: 0,
|
||||
diffStat: null,
|
||||
@@ -51,7 +51,7 @@ describe("workspace archive warning for worktree backing", () => {
|
||||
it("includes every archive risk in the confirmation copy", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveConfirmationMessage({
|
||||
workspaceName: "risky-feature",
|
||||
worktreeName: "risky-feature",
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 1,
|
||||
diffStat: { additions: 1, deletions: 3 },
|
||||
|
||||
@@ -14,11 +14,11 @@ export interface WorktreeArchiveRiskInput {
|
||||
}
|
||||
|
||||
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
|
||||
workspaceName: string;
|
||||
worktreeName: string;
|
||||
}
|
||||
|
||||
export interface WorktreeArchiveWarningLabels {
|
||||
title: (workspaceName: string) => string;
|
||||
title: (worktreeName: string) => string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
uncommittedChanges: string;
|
||||
@@ -29,7 +29,7 @@ export interface WorktreeArchiveWarningLabels {
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabels = {
|
||||
title: (workspaceName) => i18n.t("workspace.git.actions.archiveWarning.title", { workspaceName }),
|
||||
title: (worktreeName) => i18n.t("workspace.git.actions.archiveWarning.title", { worktreeName }),
|
||||
confirm: i18n.t("workspace.git.actions.archiveWarning.confirm"),
|
||||
cancel: i18n.t("workspace.git.actions.archiveWarning.cancel"),
|
||||
uncommittedChanges: i18n.t("workspace.git.actions.archiveWarning.uncommittedChanges"),
|
||||
@@ -123,7 +123,7 @@ export async function confirmRiskyWorktreeArchive(
|
||||
}
|
||||
|
||||
return await confirmDialog({
|
||||
title: labels.title(input.workspaceName),
|
||||
title: labels.title(input.worktreeName),
|
||||
message,
|
||||
confirmLabel: labels.confirm,
|
||||
cancelLabel: labels.cancel,
|
||||
|
||||
@@ -370,7 +370,6 @@ describe("translation resources", () => {
|
||||
expect(en.sidebar.host.switchTitle).toBe("Switch host");
|
||||
expect(en.sidebar.host.searchPlaceholder).toBe("Search hosts...");
|
||||
expect(en.sidebar.actions.addProject).toBe("Add project");
|
||||
expect(en.sidebar.actions.hosts).toBe("Hosts");
|
||||
expect(en.sidebar.actions.home).toBe("Home");
|
||||
expect(en.sidebar.actions.settings).toBe("Settings");
|
||||
expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar");
|
||||
|
||||
@@ -619,7 +619,7 @@ export const ar: TranslationResources = {
|
||||
success: "تم التحديث",
|
||||
},
|
||||
archive: {
|
||||
label: "أرشفة مساحة العمل",
|
||||
label: "أرشفة شجرة العمل",
|
||||
pending: "أرشفة...",
|
||||
success: "مؤرشف",
|
||||
},
|
||||
@@ -661,6 +661,8 @@ export const ar: TranslationResources = {
|
||||
updateNoBase: "التحديث غير متاح لأننا لم نتمكن من تحديد الفرع الأساسي",
|
||||
updateDirty: "التحديث غير متاح أثناء وجود تغييرات محلية، لذا قم بتنفيذها أو تخزينها أولاً",
|
||||
updateCurrent: "التحديث غير متاح لأن هذا الفرع محدث بالفعل باستخدام{{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"الأرشيف غير متاح هنا لأنه لم يتم إنشاء مساحة العمل هذه كشجرة عمل Paseo",
|
||||
mergePrNoGithub: "دمج PR غير متاح الآن لأن GitHub غير متصل",
|
||||
mergePrMissing: "دمج PR غير متاح لأنه لا يوجد طلب سحب حتى الآن",
|
||||
mergePrDraft: "دمج PR غير متاح لأن طلب السحب لا يزال مسودة",
|
||||
@@ -683,9 +685,11 @@ export const ar: TranslationResources = {
|
||||
baseRefUnavailable: "المرجع الأساسي غير متاح",
|
||||
failedMerge: "فشل الدمج",
|
||||
failedMergeFromBase: "فشل الدمج من القاعدة",
|
||||
worktreePathUnavailable: "مسار شجرة العمل غير متوفر",
|
||||
failedArchive: "فشل في أرشفة شجرة العمل",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: 'الأرشيف "{{workspaceName}}"؟',
|
||||
title: 'الأرشيف "{{worktreeName}}"؟',
|
||||
confirm: "أرشيف",
|
||||
cancel: "يلغي",
|
||||
uncommittedChanges: "تغييرات غير ملتزم بها",
|
||||
@@ -782,7 +786,6 @@ export const ar: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "إضافة مشروع",
|
||||
newWorkspace: "مساحة عمل جديدة",
|
||||
hosts: "المضيفون",
|
||||
home: "بيت",
|
||||
settings: "إعدادات",
|
||||
closeSidebar: "إغلاق الشريط الجانبي",
|
||||
@@ -836,7 +839,7 @@ export const ar: TranslationResources = {
|
||||
copyBranchName: "انسخ اسم الفرع",
|
||||
rename: "إعادة تسمية مساحة العمل",
|
||||
archive: "أرشيف",
|
||||
archiveWorkspace: "أرشفة مساحة العمل",
|
||||
archiveWorktree: "أرشفة شجرة العمل",
|
||||
hideFromSidebar: "إخفاء من الشريط الجانبي",
|
||||
archiving: "أرشفة...",
|
||||
hiding: "إخفاء...",
|
||||
@@ -859,7 +862,7 @@ export const ar: TranslationResources = {
|
||||
branchNameCopied: "تم نسخ اسم الفرع",
|
||||
hostDisconnected: "Host غير متصل",
|
||||
hideFailed: "فشل في إخفاء مساحة العمل",
|
||||
archiveFailed: "فشل في أرشفة مساحة العمل",
|
||||
archiveFailed: "فشل في أرشفة شجرة العمل",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1602,7 +1605,7 @@ export const ar: TranslationResources = {
|
||||
openProject: "مشروع مفتوح",
|
||||
newWorkspace: "مساحة عمل جديدة",
|
||||
newWorktree: "شجرة عمل جديدة",
|
||||
archiveWorkspace: "أرشفة مساحة العمل",
|
||||
archiveWorktree: "أرشفة شجرة العمل",
|
||||
newTab: "علامة تبويب جديدة",
|
||||
closeCurrentTab: "إغلاق علامة التبويب الحالية",
|
||||
jumpToWorkspace: "انتقل إلى مساحة العمل",
|
||||
|
||||
@@ -618,7 +618,7 @@ export const en = {
|
||||
success: "Updated",
|
||||
},
|
||||
archive: {
|
||||
label: "Archive workspace",
|
||||
label: "Archive worktree",
|
||||
pending: "Archiving...",
|
||||
success: "Archived",
|
||||
},
|
||||
@@ -667,6 +667,8 @@ export const en = {
|
||||
"Update isn't available while you have local changes so commit or stash them first",
|
||||
updateCurrent:
|
||||
"Update isn't available because this branch is already up to date with {{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"Archive isn't available here because this workspace was not created as a Paseo worktree",
|
||||
mergePrNoGithub: "Merge PR isn't available right now because GitHub isn't connected",
|
||||
mergePrMissing: "Merge PR isn't available because there isn't a pull request yet",
|
||||
mergePrDraft: "Merge PR isn't available because the pull request is still a draft",
|
||||
@@ -690,9 +692,11 @@ export const en = {
|
||||
baseRefUnavailable: "Base ref unavailable",
|
||||
failedMerge: "Failed to merge",
|
||||
failedMergeFromBase: "Failed to merge from base",
|
||||
worktreePathUnavailable: "Worktree path unavailable",
|
||||
failedArchive: "Failed to archive worktree",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: 'Archive "{{workspaceName}}"?',
|
||||
title: 'Archive "{{worktreeName}}"?',
|
||||
confirm: "Archive",
|
||||
cancel: "Cancel",
|
||||
uncommittedChanges: "Uncommitted changes",
|
||||
@@ -789,7 +793,6 @@ export const en = {
|
||||
actions: {
|
||||
addProject: "Add project",
|
||||
newWorkspace: "New workspace",
|
||||
hosts: "Hosts",
|
||||
home: "Home",
|
||||
settings: "Settings",
|
||||
closeSidebar: "Close sidebar",
|
||||
@@ -843,7 +846,7 @@ export const en = {
|
||||
copyBranchName: "Copy branch name",
|
||||
rename: "Rename workspace",
|
||||
archive: "Archive",
|
||||
archiveWorkspace: "Archive workspace",
|
||||
archiveWorktree: "Archive worktree",
|
||||
hideFromSidebar: "Hide from sidebar",
|
||||
archiving: "Archiving...",
|
||||
hiding: "Hiding...",
|
||||
@@ -866,7 +869,7 @@ export const en = {
|
||||
branchNameCopied: "Branch name copied",
|
||||
hostDisconnected: "Host is not connected",
|
||||
hideFailed: "Failed to hide workspace",
|
||||
archiveFailed: "Failed to archive workspace",
|
||||
archiveFailed: "Failed to archive worktree",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1609,7 +1612,7 @@ export const en = {
|
||||
openProject: "Open project",
|
||||
newWorkspace: "New workspace",
|
||||
newWorktree: "New worktree",
|
||||
archiveWorkspace: "Archive workspace",
|
||||
archiveWorktree: "Archive worktree",
|
||||
newTab: "New tab",
|
||||
closeCurrentTab: "Close current tab",
|
||||
jumpToWorkspace: "Jump to workspace",
|
||||
|
||||
@@ -625,7 +625,7 @@ export const es: TranslationResources = {
|
||||
success: "Actualizado",
|
||||
},
|
||||
archive: {
|
||||
label: "Archivar espacio de trabajo",
|
||||
label: "Árbol de trabajo de archivo",
|
||||
pending: "Archivando...",
|
||||
success: "Archivado",
|
||||
},
|
||||
@@ -679,6 +679,8 @@ export const es: TranslationResources = {
|
||||
"La actualización no está disponible mientras tenga cambios locales, así que confírmelos o guárdelos primero",
|
||||
updateCurrent:
|
||||
"La actualización no está disponible porque esta rama ya está actualizada con{{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"El archivo no está disponible aquí porque este espacio de trabajo no se creó como un árbol de trabajoPaseo",
|
||||
mergePrNoGithub:
|
||||
"FusionarPRno está disponible en este momento porqueGitHubno está conectado",
|
||||
mergePrMissing:
|
||||
@@ -710,9 +712,11 @@ export const es: TranslationResources = {
|
||||
baseRefUnavailable: "Referencia base no disponible",
|
||||
failedMerge: "No se pudo fusionar",
|
||||
failedMergeFromBase: "No se pudo fusionar desde la base",
|
||||
worktreePathUnavailable: "Ruta del árbol de trabajo no disponible",
|
||||
failedArchive: "No se pudo archivar el árbol de trabajo",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: '¿Archivo "{{workspaceName}}"?',
|
||||
title: '¿Archivo "{{worktreeName}}"?',
|
||||
confirm: "Archivo",
|
||||
cancel: "Cancelar",
|
||||
uncommittedChanges: "Cambios no confirmados",
|
||||
@@ -809,7 +813,6 @@ export const es: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "Agregar proyecto",
|
||||
newWorkspace: "Nuevo espacio de trabajo",
|
||||
hosts: "Hosts",
|
||||
home: "Hogar",
|
||||
settings: "Ajustes",
|
||||
closeSidebar: "Cerrar barra lateral",
|
||||
@@ -863,7 +866,7 @@ export const es: TranslationResources = {
|
||||
copyBranchName: "Copiar nombre de sucursal",
|
||||
rename: "Cambiar nombre del espacio de trabajo",
|
||||
archive: "Archivo",
|
||||
archiveWorkspace: "Archivar espacio de trabajo",
|
||||
archiveWorktree: "Árbol de trabajo de archivo",
|
||||
hideFromSidebar: "Ocultar de la barra lateral",
|
||||
archiving: "Archivando...",
|
||||
hiding: "Ocultación...",
|
||||
@@ -886,7 +889,7 @@ export const es: TranslationResources = {
|
||||
branchNameCopied: "Nombre de la sucursal copiado",
|
||||
hostDisconnected: "Hostno está conectado",
|
||||
hideFailed: "No se pudo ocultar el espacio de trabajo",
|
||||
archiveFailed: "No se pudo archivar el espacio de trabajo",
|
||||
archiveFailed: "No se pudo archivar el árbol de trabajo",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1644,7 +1647,7 @@ export const es: TranslationResources = {
|
||||
openProject: "Abrir proyecto",
|
||||
newWorkspace: "Nuevo espacio de trabajo",
|
||||
newWorktree: "Nuevo árbol de trabajo",
|
||||
archiveWorkspace: "Archivar espacio de trabajo",
|
||||
archiveWorktree: "Árbol de trabajo de archivo",
|
||||
newTab: "Nueva pestaña",
|
||||
closeCurrentTab: "Cerrar pestaña actual",
|
||||
jumpToWorkspace: "Saltar al espacio de trabajo",
|
||||
|
||||
@@ -625,7 +625,7 @@ export const fr: TranslationResources = {
|
||||
success: "Mis à jour",
|
||||
},
|
||||
archive: {
|
||||
label: "Archiver l’espace de travail",
|
||||
label: "Arbre de travail d'archivage",
|
||||
pending: "Archivage...",
|
||||
success: "Archivé",
|
||||
},
|
||||
@@ -679,6 +679,8 @@ export const fr: TranslationResources = {
|
||||
"La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord",
|
||||
updateCurrent:
|
||||
"La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo",
|
||||
mergePrNoGithub:
|
||||
"La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté",
|
||||
mergePrMissing:
|
||||
@@ -709,9 +711,11 @@ export const fr: TranslationResources = {
|
||||
baseRefUnavailable: "Réf de base indisponible",
|
||||
failedMerge: "Échec de la fusion",
|
||||
failedMergeFromBase: "Échec de la fusion à partir de la base",
|
||||
worktreePathUnavailable: "Chemin d'accès à l'arbre de travail indisponible",
|
||||
failedArchive: "Échec de l'archivage de l'arbre de travail",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: "Archiver «{{workspaceName}}»?",
|
||||
title: "Archiver «{{worktreeName}}»?",
|
||||
confirm: "Archive",
|
||||
cancel: "Annuler",
|
||||
uncommittedChanges: "Modifications non validées",
|
||||
@@ -808,7 +812,6 @@ export const fr: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "Ajouter un projet",
|
||||
newWorkspace: "Nouvel espace de travail",
|
||||
hosts: "Hôtes",
|
||||
home: "Maison",
|
||||
settings: "Paramètres",
|
||||
closeSidebar: "Fermer la barre latérale",
|
||||
@@ -862,7 +865,7 @@ export const fr: TranslationResources = {
|
||||
copyBranchName: "Copier le nom de la branche",
|
||||
rename: "Renommer l'espace de travail",
|
||||
archive: "Archive",
|
||||
archiveWorkspace: "Archiver l’espace de travail",
|
||||
archiveWorktree: "Arbre de travail d'archivage",
|
||||
hideFromSidebar: "Masquer de la barre latérale",
|
||||
archiving: "Archivage...",
|
||||
hiding: "Dissimulation...",
|
||||
@@ -885,7 +888,7 @@ export const fr: TranslationResources = {
|
||||
branchNameCopied: "Nom de la succursale copié",
|
||||
hostDisconnected: "Hostn'est pas connecté",
|
||||
hideFailed: "Échec du masquage de l'espace de travail",
|
||||
archiveFailed: "Échec de l'archivage de l'espace de travail",
|
||||
archiveFailed: "Échec de l'archivage de l'arbre de travail",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1647,7 +1650,7 @@ export const fr: TranslationResources = {
|
||||
openProject: "Projet ouvert",
|
||||
newWorkspace: "Nouvel espace de travail",
|
||||
newWorktree: "Nouvel arbre de travail",
|
||||
archiveWorkspace: "Archiver l’espace de travail",
|
||||
archiveWorktree: "Arbre de travail d'archivage",
|
||||
newTab: "Nouvel onglet",
|
||||
closeCurrentTab: "Fermer l'onglet actuel",
|
||||
jumpToWorkspace: "Accéder à l'espace de travail",
|
||||
|
||||
@@ -623,7 +623,7 @@ export const ja: TranslationResources = {
|
||||
success: "更新しました",
|
||||
},
|
||||
archive: {
|
||||
label: "ワークスペースをアーカイブ",
|
||||
label: "ワークツリーをアーカイブ",
|
||||
pending: "アーカイブ中...",
|
||||
success: "アーカイブしました",
|
||||
},
|
||||
@@ -670,6 +670,8 @@ export const ja: TranslationResources = {
|
||||
updateDirty:
|
||||
"ローカルに変更があるため更新は利用できません。先にコミットまたはスタッシュしてください",
|
||||
updateCurrent: "このブランチはすでに{{baseRef}}と最新の状態のため、更新は利用できません",
|
||||
archiveNotWorktree:
|
||||
"このワークスペースはPaseoワークツリーとして作成されていないため、アーカイブはここでは利用できません",
|
||||
mergePrNoGithub: "GitHubが接続されていないため、PRのマージは現在利用できません",
|
||||
mergePrMissing: "プルリクエストがまだないため、PRのマージは利用できません",
|
||||
mergePrDraft: "プルリクエストがまだドラフトのため、PRのマージは利用できません",
|
||||
@@ -695,9 +697,11 @@ export const ja: TranslationResources = {
|
||||
baseRefUnavailable: "ベースRefが利用できません",
|
||||
failedMerge: "マージに失敗しました",
|
||||
failedMergeFromBase: "ベースからのマージに失敗しました",
|
||||
worktreePathUnavailable: "ワークツリーパスが利用できません",
|
||||
failedArchive: "ワークツリーのアーカイブに失敗しました",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: '"{{workspaceName}}"をアーカイブしますか?',
|
||||
title: '"{{worktreeName}}"をアーカイブしますか?',
|
||||
confirm: "アーカイブ",
|
||||
cancel: "キャンセル",
|
||||
uncommittedChanges: "未コミットの変更",
|
||||
@@ -794,7 +798,6 @@ export const ja: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "プロジェクトを追加",
|
||||
newWorkspace: "新しいワークスペース",
|
||||
hosts: "ホスト",
|
||||
home: "ホーム",
|
||||
settings: "設定",
|
||||
closeSidebar: "サイドバーを閉じる",
|
||||
@@ -848,7 +851,7 @@ export const ja: TranslationResources = {
|
||||
copyBranchName: "ブランチ名をコピー",
|
||||
rename: "ワークスペースの名前を変更",
|
||||
archive: "アーカイブ",
|
||||
archiveWorkspace: "ワークスペースをアーカイブ",
|
||||
archiveWorktree: "ワークツリーをアーカイブ",
|
||||
hideFromSidebar: "サイドバーから非表示",
|
||||
archiving: "アーカイブ中...",
|
||||
hiding: "非表示にしています...",
|
||||
@@ -871,7 +874,7 @@ export const ja: TranslationResources = {
|
||||
branchNameCopied: "ブランチ名をコピーしました",
|
||||
hostDisconnected: "ホストが接続されていません",
|
||||
hideFailed: "ワークスペースの非表示に失敗しました",
|
||||
archiveFailed: "ワークスペースのアーカイブに失敗しました",
|
||||
archiveFailed: "ワークツリーのアーカイブに失敗しました",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1619,7 +1622,7 @@ export const ja: TranslationResources = {
|
||||
openProject: "プロジェクトを開く",
|
||||
newWorkspace: "新しいワークスペース",
|
||||
newWorktree: "新しいワークツリー",
|
||||
archiveWorkspace: "ワークスペースをアーカイブ",
|
||||
archiveWorktree: "ワークツリーをアーカイブ",
|
||||
newTab: "新しいタブ",
|
||||
closeCurrentTab: "現在のタブを閉じる",
|
||||
jumpToWorkspace: "ワークスペースにジャンプ",
|
||||
|
||||
@@ -623,7 +623,7 @@ export const ptBR: TranslationResources = {
|
||||
success: "Atualizado",
|
||||
},
|
||||
archive: {
|
||||
label: "Arquivar workspace",
|
||||
label: "Arquivar worktree",
|
||||
pending: "Arquivando...",
|
||||
success: "Arquivado",
|
||||
},
|
||||
@@ -675,6 +675,8 @@ export const ptBR: TranslationResources = {
|
||||
"Atualizar não está disponível enquanto há alterações locais. Faça commit ou stash primeiro",
|
||||
updateCurrent:
|
||||
"Atualizar não está disponível porque esta branch já está atualizada com {{baseRef}}",
|
||||
archiveNotWorktree:
|
||||
"Arquivar não está disponível aqui porque este workspace não foi criado como um worktree do Paseo",
|
||||
mergePrNoGithub:
|
||||
"Merge da PR não está disponível agora porque o GitHub não está conectado",
|
||||
mergePrMissing: "Merge da PR não está disponível porque ainda não há uma pull request",
|
||||
@@ -701,9 +703,11 @@ export const ptBR: TranslationResources = {
|
||||
baseRefUnavailable: "Ref base indisponível",
|
||||
failedMerge: "Falha ao fazer merge",
|
||||
failedMergeFromBase: "Falha ao fazer merge da base",
|
||||
worktreePathUnavailable: "Caminho do worktree indisponível",
|
||||
failedArchive: "Falha ao arquivar worktree",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: 'Arquivar "{{workspaceName}}"?',
|
||||
title: 'Arquivar "{{worktreeName}}"?',
|
||||
confirm: "Arquivar",
|
||||
cancel: "Cancelar",
|
||||
uncommittedChanges: "Alterações sem commit",
|
||||
@@ -800,7 +804,6 @@ export const ptBR: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "Adicionar projeto",
|
||||
newWorkspace: "Novo workspace",
|
||||
hosts: "Hosts",
|
||||
home: "Início",
|
||||
settings: "Configurações",
|
||||
closeSidebar: "Fechar barra lateral",
|
||||
@@ -854,7 +857,7 @@ export const ptBR: TranslationResources = {
|
||||
copyBranchName: "Copiar nome da branch",
|
||||
rename: "Renomear workspace",
|
||||
archive: "Arquivar",
|
||||
archiveWorkspace: "Arquivar workspace",
|
||||
archiveWorktree: "Arquivar worktree",
|
||||
hideFromSidebar: "Ocultar da barra lateral",
|
||||
archiving: "Arquivando...",
|
||||
hiding: "Ocultando...",
|
||||
@@ -877,7 +880,7 @@ export const ptBR: TranslationResources = {
|
||||
branchNameCopied: "Nome da branch copiado",
|
||||
hostDisconnected: "Host não está conectado",
|
||||
hideFailed: "Falha ao ocultar workspace",
|
||||
archiveFailed: "Falha ao arquivar workspace",
|
||||
archiveFailed: "Falha ao arquivar worktree",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1628,7 +1631,7 @@ export const ptBR: TranslationResources = {
|
||||
openProject: "Abrir projeto",
|
||||
newWorkspace: "Novo workspace",
|
||||
newWorktree: "Novo worktree",
|
||||
archiveWorkspace: "Arquivar workspace",
|
||||
archiveWorktree: "Arquivar worktree",
|
||||
newTab: "Nova aba",
|
||||
closeCurrentTab: "Fechar aba atual",
|
||||
jumpToWorkspace: "Ir para workspace",
|
||||
|
||||
@@ -624,7 +624,7 @@ export const ru: TranslationResources = {
|
||||
success: "Обновлено",
|
||||
},
|
||||
archive: {
|
||||
label: "Архивировать рабочее пространство",
|
||||
label: "Архив рабочего дерева",
|
||||
pending: "Архивирование...",
|
||||
success: "В архиве",
|
||||
},
|
||||
@@ -675,6 +675,8 @@ export const ru: TranslationResources = {
|
||||
"Обновление недоступно, пока у вас есть локальные изменения, поэтому сначала зафиксируйте или сохраните их.",
|
||||
updateCurrent:
|
||||
"Обновление недоступно, поскольку эта ветка уже обновлена до версии{{baseRef}}.",
|
||||
archiveNotWorktree:
|
||||
"Архив здесь недоступен, поскольку это рабочее пространство не было создано как рабочее дерево Paseo.",
|
||||
mergePrNoGithub: "Объединение PR сейчас недоступно, поскольку GitHub не подключен.",
|
||||
mergePrMissing: "Объединение PR недоступно, поскольку еще нет запроса на включение",
|
||||
mergePrDraft:
|
||||
@@ -702,9 +704,11 @@ export const ru: TranslationResources = {
|
||||
baseRefUnavailable: "Базовый номер недоступен.",
|
||||
failedMerge: "Не удалось объединиться",
|
||||
failedMergeFromBase: "Не удалось объединиться с базой.",
|
||||
worktreePathUnavailable: "Путь к рабочему дереву недоступен.",
|
||||
failedArchive: "Не удалось заархивировать рабочее дерево.",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: 'Архив "{{workspaceName}}"?',
|
||||
title: 'Архив "{{worktreeName}}"?',
|
||||
confirm: "Архив",
|
||||
cancel: "Отмена",
|
||||
uncommittedChanges: "Незафиксированные изменения",
|
||||
@@ -801,7 +805,6 @@ export const ru: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "Добавить проект",
|
||||
newWorkspace: "Новое рабочее пространство",
|
||||
hosts: "Хосты",
|
||||
home: "Дом",
|
||||
settings: "Настройки",
|
||||
closeSidebar: "Закрыть боковую панель",
|
||||
@@ -855,7 +858,7 @@ export const ru: TranslationResources = {
|
||||
copyBranchName: "Скопировать название ветки",
|
||||
rename: "Переименовать рабочую область",
|
||||
archive: "Архив",
|
||||
archiveWorkspace: "Архивировать рабочее пространство",
|
||||
archiveWorktree: "Архив рабочего дерева",
|
||||
hideFromSidebar: "Скрыть с боковой панели",
|
||||
archiving: "Архивирование...",
|
||||
hiding: "Скрытие...",
|
||||
@@ -878,7 +881,7 @@ export const ru: TranslationResources = {
|
||||
branchNameCopied: "Название филиала скопировано.",
|
||||
hostDisconnected: "Host не подключен",
|
||||
hideFailed: "Не удалось скрыть рабочую область.",
|
||||
archiveFailed: "Не удалось заархивировать рабочее пространство.",
|
||||
archiveFailed: "Не удалось заархивировать рабочее дерево.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1636,7 +1639,7 @@ export const ru: TranslationResources = {
|
||||
openProject: "Открыть проект",
|
||||
newWorkspace: "Новое рабочее пространство",
|
||||
newWorktree: "Новое рабочее дерево",
|
||||
archiveWorkspace: "Архивировать рабочее пространство",
|
||||
archiveWorktree: "Архив рабочего дерева",
|
||||
newTab: "Новая вкладка",
|
||||
closeCurrentTab: "Закрыть текущую вкладку",
|
||||
jumpToWorkspace: "Перейти в рабочую область",
|
||||
|
||||
@@ -617,7 +617,7 @@ export const zhCN: TranslationResources = {
|
||||
success: "已更新",
|
||||
},
|
||||
archive: {
|
||||
label: "归档工作区",
|
||||
label: "归档 worktree",
|
||||
pending: "正在归档...",
|
||||
success: "已归档",
|
||||
},
|
||||
@@ -656,6 +656,7 @@ export const zhCN: TranslationResources = {
|
||||
updateNoBase: "无法更新,因为无法确定 base branch",
|
||||
updateDirty: "有本地变更时无法更新,请先 commit 或 stash",
|
||||
updateCurrent: "无法更新,因为此分支已与 {{baseRef}} 保持最新",
|
||||
archiveNotWorktree: "此处无法归档,因为此 workspace 不是作为 Paseo worktree 创建的",
|
||||
mergePrNoGithub: "当前无法 merge PR,因为 GitHub 未连接",
|
||||
mergePrMissing: "无法 merge PR,因为还没有 pull request",
|
||||
mergePrDraft: "无法 merge PR,因为 pull request 仍是 draft",
|
||||
@@ -678,9 +679,11 @@ export const zhCN: TranslationResources = {
|
||||
baseRefUnavailable: "Base ref 不可用",
|
||||
failedMerge: "Merge 失败",
|
||||
failedMergeFromBase: "从 base merge 失败",
|
||||
worktreePathUnavailable: "Worktree 路径不可用",
|
||||
failedArchive: "归档 worktree 失败",
|
||||
},
|
||||
archiveWarning: {
|
||||
title: "归档「{{workspaceName}}」?",
|
||||
title: "归档「{{worktreeName}}」?",
|
||||
confirm: "归档",
|
||||
cancel: "取消",
|
||||
uncommittedChanges: "未 commit 的变更",
|
||||
@@ -777,7 +780,6 @@ export const zhCN: TranslationResources = {
|
||||
actions: {
|
||||
addProject: "添加 project",
|
||||
newWorkspace: "新建工作区",
|
||||
hosts: "Hosts",
|
||||
home: "首页",
|
||||
settings: "设置",
|
||||
closeSidebar: "关闭侧边栏",
|
||||
@@ -829,7 +831,7 @@ export const zhCN: TranslationResources = {
|
||||
copyBranchName: "复制分支名称",
|
||||
rename: "重命名 workspace",
|
||||
archive: "归档",
|
||||
archiveWorkspace: "归档工作区",
|
||||
archiveWorktree: "归档 worktree",
|
||||
hideFromSidebar: "从侧边栏隐藏",
|
||||
archiving: "正在归档...",
|
||||
hiding: "正在隐藏...",
|
||||
@@ -851,7 +853,7 @@ export const zhCN: TranslationResources = {
|
||||
branchNameCopied: "分支名称已复制",
|
||||
hostDisconnected: "Host 未连接",
|
||||
hideFailed: "隐藏 workspace 失败",
|
||||
archiveFailed: "归档工作区失败",
|
||||
archiveFailed: "归档 worktree 失败",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1584,7 +1586,7 @@ export const zhCN: TranslationResources = {
|
||||
openProject: "打开项目",
|
||||
newWorkspace: "新建 workspace",
|
||||
newWorktree: "新建 worktree",
|
||||
archiveWorkspace: "归档工作区",
|
||||
archiveWorktree: "归档 worktree",
|
||||
newTab: "新建标签",
|
||||
closeCurrentTab: "关闭当前标签",
|
||||
jumpToWorkspace: "跳转到 workspace",
|
||||
|
||||
@@ -44,7 +44,7 @@ export type KeyboardActionId =
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.new"
|
||||
| "worktree.new"
|
||||
| "workspace.archive"
|
||||
| "worktree.archive"
|
||||
| "view.toggle.focus"
|
||||
| "theme.cycle"
|
||||
| "message-input.action";
|
||||
|
||||
@@ -29,7 +29,7 @@ export type KeyboardActionId =
|
||||
| "sidebar.toggle.right"
|
||||
| "workspace.new"
|
||||
| "worktree.new"
|
||||
| "workspace.archive";
|
||||
| "worktree.archive";
|
||||
|
||||
export type KeyboardActionDefinition =
|
||||
| { id: "agent.interrupt"; scope: KeyboardActionScope }
|
||||
@@ -60,7 +60,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.new"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.archive"; scope: KeyboardActionScope };
|
||||
| { id: "worktree.archive"; scope: KeyboardActionScope };
|
||||
|
||||
export interface KeyboardActionHandler {
|
||||
handlerId: string;
|
||||
|
||||
@@ -121,7 +121,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
|
||||
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"new-agent": "settings.shortcuts.help.openProject",
|
||||
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
||||
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
|
||||
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
|
||||
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
||||
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
|
||||
"workspace-jump-index": "settings.shortcuts.help.jumpToWorkspace",
|
||||
@@ -221,32 +221,28 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- Archive workspace ---
|
||||
// --- Archive worktree ---
|
||||
{
|
||||
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
||||
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
|
||||
id: "worktree-archive-cmd-shift-backspace-mac",
|
||||
action: "workspace.archive",
|
||||
action: "worktree.archive",
|
||||
combo: "Cmd+Shift+Backspace",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "archive-workspace",
|
||||
id: "archive-worktree",
|
||||
section: "projects",
|
||||
label: "Archive workspace",
|
||||
label: "Archive worktree",
|
||||
keys: ["mod", "shift", "Backspace"],
|
||||
},
|
||||
},
|
||||
{
|
||||
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after
|
||||
// 2027-01-11 with a stored-override migration. Keeps existing custom chords.
|
||||
id: "worktree-archive-ctrl-shift-backspace-non-mac",
|
||||
action: "workspace.archive",
|
||||
action: "worktree.archive",
|
||||
combo: "Ctrl+Shift+Backspace",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "archive-workspace",
|
||||
id: "archive-worktree",
|
||||
section: "projects",
|
||||
label: "Archive workspace",
|
||||
label: "Archive worktree",
|
||||
keys: ["mod", "shift", "Backspace"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
|
||||
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
|
||||
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
|
||||
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
|
||||
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }],
|
||||
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
|
||||
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
|
||||
["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
|
||||
["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }],
|
||||
|
||||
@@ -46,7 +46,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
|
||||
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
|
||||
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
|
||||
"workspace.new": { id: "workspace.new", scope: "sidebar" },
|
||||
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
|
||||
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
|
||||
"worktree.new": { id: "worktree.new", scope: "sidebar" },
|
||||
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
|
||||
"workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" },
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||
import { Stack } from "expo-router";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
|
||||
interface ThemedStackBaseProps {
|
||||
backgroundColor: string;
|
||||
children?: ReactNode;
|
||||
screenOptions?: NativeStackNavigationOptions;
|
||||
}
|
||||
|
||||
function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedStackBaseProps) {
|
||||
const themedScreenOptions = useMemo<NativeStackNavigationOptions>(
|
||||
() => ({
|
||||
...screenOptions,
|
||||
contentStyle: [{ backgroundColor }, screenOptions?.contentStyle],
|
||||
}),
|
||||
[backgroundColor, screenOptions],
|
||||
);
|
||||
|
||||
return <Stack screenOptions={themedScreenOptions}>{children}</Stack>;
|
||||
}
|
||||
|
||||
export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
}));
|
||||
@@ -1270,7 +1270,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
ref={streamViewRef}
|
||||
agentId={agent.id}
|
||||
serverId={serverId}
|
||||
context={agent}
|
||||
agent={agent}
|
||||
streamItems={streamItems}
|
||||
pendingPermissions={pendingPermissions}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
@@ -1364,7 +1364,7 @@ function ActiveAgentComposer({
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const paneContext = usePaneContext();
|
||||
const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext;
|
||||
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||
@@ -1382,12 +1382,6 @@ function ActiveAgentComposer({
|
||||
},
|
||||
[serverId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
},
|
||||
[openTab],
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
@@ -1488,7 +1482,6 @@ function ActiveAgentComposer({
|
||||
<SubagentsTrack
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
onArchiveSubagent={handleArchiveSubagent}
|
||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||
/>
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { AgentStreamView } from "@/agent-stream/view";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
providerSubagentKey,
|
||||
providerSubagentLifecycleStatus,
|
||||
refreshProviderSubagents,
|
||||
useProviderSubagentStore,
|
||||
} from "@/subagents/provider-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
|
||||
const EMPTY_PERMISSIONS = new Map<string, PendingPermission>();
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
function formatProviderLabel(provider: string): string {
|
||||
return provider
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function useProviderSubagentDescriptor(
|
||||
target: { kind: "provider_subagent"; parentAgentId: string; subagentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const descriptor = useProviderSubagentStore((state) =>
|
||||
state.descriptors.get(
|
||||
providerSubagentKey(context.serverId, target.parentAgentId, target.subagentId),
|
||||
),
|
||||
);
|
||||
const parentProvider = useSessionStore(
|
||||
(state) => state.sessions[context.serverId]?.agents.get(target.parentAgentId)?.provider,
|
||||
);
|
||||
const provider = descriptor?.provider ?? parentProvider ?? "agent";
|
||||
const label = descriptor?.title?.trim() || descriptor?.description?.trim() || "Subagent";
|
||||
return {
|
||||
label,
|
||||
subtitle: `${formatProviderLabel(provider)} subagent`,
|
||||
titleState: descriptor ? "ready" : "loading",
|
||||
icon: getProviderIcon(provider),
|
||||
statusBucket: descriptor
|
||||
? deriveSidebarStateBucket({
|
||||
status: providerSubagentLifecycleStatus(descriptor.status),
|
||||
requiresAttention: descriptor.status === "failed",
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function ProviderSubagentPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, target, openFileInWorkspace } = usePaneContext();
|
||||
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
|
||||
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
|
||||
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
|
||||
const { descriptor, timeline } = useProviderSubagentStore(
|
||||
useShallow((state) => ({
|
||||
descriptor: state.descriptors.get(key) ?? null,
|
||||
timeline: state.timelines.get(key) ?? null,
|
||||
})),
|
||||
);
|
||||
const parent = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[serverId]?.agents.get(target.parentAgentId) ??
|
||||
state.sessions[serverId]?.agentDetails.get(target.parentAgentId) ??
|
||||
null,
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
|
||||
// COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12.
|
||||
const supported = serverInfo?.features?.providerSubagents === true;
|
||||
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !supported) return;
|
||||
void refreshProviderSubagents(client, serverId, target.parentAgentId).catch(() => undefined);
|
||||
}, [client, serverId, supported, target.parentAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !supported) return;
|
||||
void client
|
||||
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
})
|
||||
.then((payload) => {
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [client, serverId, supported, target.parentAgentId, target.subagentId]);
|
||||
|
||||
const loadOlder = useCallback(() => {
|
||||
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return;
|
||||
const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null;
|
||||
if (firstSeq === null) return;
|
||||
setIsLoadingOlder(true);
|
||||
void client
|
||||
.fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: timeline.epoch, seq: firstSeq },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
})
|
||||
.then((payload) => {
|
||||
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setIsLoadingOlder(false));
|
||||
}, [
|
||||
client,
|
||||
isLoadingOlder,
|
||||
serverId,
|
||||
supported,
|
||||
target.parentAgentId,
|
||||
target.subagentId,
|
||||
timeline,
|
||||
]);
|
||||
|
||||
const streamContext = useMemo<AgentScreenAgent>(
|
||||
() => ({
|
||||
serverId,
|
||||
id: streamId,
|
||||
provider: descriptor?.provider ?? parent?.provider,
|
||||
status: descriptor ? providerSubagentLifecycleStatus(descriptor.status) : "initializing",
|
||||
cwd: descriptor?.cwd ?? parent?.cwd ?? "",
|
||||
workspaceId: parent?.workspaceId,
|
||||
projectPlacement: parent?.projectPlacement,
|
||||
}),
|
||||
[descriptor, parent, serverId, streamId],
|
||||
);
|
||||
const historyPagination = useMemo(
|
||||
() => ({
|
||||
hasOlder: timeline?.hasOlder === true,
|
||||
isLoadingOlder,
|
||||
onLoadOlder: loadOlder,
|
||||
}),
|
||||
[isLoadingOlder, loadOlder, timeline?.hasOlder],
|
||||
);
|
||||
|
||||
if (serverInfo && !supported) {
|
||||
return (
|
||||
<View style={styles.unsupported} testID="provider-subagent-panel-unsupported">
|
||||
<Text style={styles.unsupportedText}>{t("message.actions.forkUnavailable")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="provider-subagent-panel">
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1 },
|
||||
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||
}));
|
||||
|
||||
export const providerSubagentPanelRegistration: PanelRegistration<"provider_subagent"> = {
|
||||
kind: "provider_subagent",
|
||||
component: ProviderSubagentPanel,
|
||||
useDescriptor: useProviderSubagentDescriptor,
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
import { setupPanelRegistration } from "@/panels/setup-panel";
|
||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
|
||||
|
||||
let panelsRegistered = false;
|
||||
|
||||
@@ -15,7 +14,6 @@ export function ensurePanelsRegistered(): void {
|
||||
}
|
||||
registerPanel(draftPanelRegistration);
|
||||
registerPanel(agentPanelRegistration);
|
||||
registerPanel(providerSubagentPanelRegistration);
|
||||
registerPanel(setupPanelRegistration);
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(browserPanelRegistration);
|
||||
|
||||
@@ -5,19 +5,11 @@ import type { TFunction } from "i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { PressableStateCallbackType } from "react-native";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native";
|
||||
import { Composer } from "@/composer";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
@@ -28,7 +20,6 @@ import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
@@ -52,7 +43,6 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useProjectPickerStore } from "@/stores/project-picker-store";
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
useWorkspaceDraftSubmissionStore,
|
||||
@@ -60,7 +50,6 @@ import {
|
||||
} from "@/stores/workspace-draft-submission-store";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -74,7 +63,6 @@ import {
|
||||
type HostProjectListItem,
|
||||
} from "@/projects/host-projects";
|
||||
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
@@ -99,12 +87,6 @@ import {
|
||||
} from "./new-workspace-initial-context";
|
||||
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
|
||||
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const addProjectIcon = (
|
||||
<ThemedFolderPlus size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
|
||||
function resolveCheckoutRequest(
|
||||
selectedItem: PickerItem | null,
|
||||
currentBranch: string | null,
|
||||
@@ -614,25 +596,6 @@ function NewWorkspaceProjectPickerOption({
|
||||
);
|
||||
}
|
||||
|
||||
function AddProjectPickerAction({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const openProjectKeys = useShortcutKeys("new-agent");
|
||||
const shortcut = useMemo(
|
||||
() => (openProjectKeys ? <Shortcut chord={openProjectKeys} /> : null),
|
||||
[openProjectKeys],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID="new-workspace-project-picker-add-project"
|
||||
label={t("sidebar.actions.addProject")}
|
||||
onPress={onPress}
|
||||
leadingSlot={addProjectIcon}
|
||||
trailingSlot={shortcut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPrLabel(item: { number: number; title: string }): string {
|
||||
return `#${item.number} ${item.title}`;
|
||||
}
|
||||
@@ -1394,7 +1357,6 @@ interface NewWorkspaceFormStackInput {
|
||||
iconDataByProjectKey: Map<string, string | null>;
|
||||
selectedOptionId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onAddProject: () => void;
|
||||
renderOption: RefPickerRenderOption;
|
||||
};
|
||||
host: FormPickerControl & {
|
||||
@@ -1432,10 +1394,6 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
|
||||
const showHostControl = host.allHosts.length > 1;
|
||||
const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation);
|
||||
const addProjectAction = useMemo(
|
||||
() => <AddProjectPickerAction onPress={project.onAddProject} />,
|
||||
[project.onAddProject],
|
||||
);
|
||||
|
||||
const badgePressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -1452,7 +1410,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
<ProjectPickerTrigger
|
||||
pickerAnchorRef={project.anchorRef}
|
||||
onPress={project.open}
|
||||
disabled={isPending}
|
||||
disabled={isPending || project.options.length === 0}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
label={project.triggerLabel}
|
||||
projectKey={project.selectedProject?.projectKey ?? null}
|
||||
@@ -1477,7 +1435,6 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
anchorRef={project.anchorRef}
|
||||
emptyText="No projects available."
|
||||
renderOption={project.renderOption}
|
||||
footer={addProjectAction}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -1632,7 +1589,6 @@ export function NewWorkspaceScreen({
|
||||
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
||||
const openAddProjectPicker = useProjectPickerStore((state) => state.open);
|
||||
const [isolationPickerOpen, setIsolationPickerOpen] = useState(false);
|
||||
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
|
||||
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
|
||||
@@ -1667,6 +1623,7 @@ export function NewWorkspaceScreen({
|
||||
lastActiveProject,
|
||||
allowAllProjects: supportsWorkspaceMultiplicity,
|
||||
});
|
||||
|
||||
const projectIconTargets = useMemo(
|
||||
() =>
|
||||
projects.flatMap((project) => {
|
||||
@@ -1840,11 +1797,6 @@ export function NewWorkspaceScreen({
|
||||
[selectProjectOption],
|
||||
);
|
||||
|
||||
const handleAddProject = useCallback(() => {
|
||||
setProjectPickerOpen(false);
|
||||
openAddProjectPicker(selectedServerId);
|
||||
}, [openAddProjectPicker, selectedServerId]);
|
||||
|
||||
const checkoutHintPrAttachment = useMemo(
|
||||
() =>
|
||||
findCheckoutHintPrAttachment({
|
||||
@@ -2147,7 +2099,6 @@ export function NewWorkspaceScreen({
|
||||
iconDataByProjectKey: projectIconDataByProjectKey,
|
||||
selectedOptionId: selectedProjectOptionId,
|
||||
onSelect: handleSelectProjectOption,
|
||||
onAddProject: handleAddProject,
|
||||
openState: projectPickerOpen,
|
||||
onOpenChange: handleProjectPickerOpenChange,
|
||||
renderOption: renderProjectOption,
|
||||
|
||||
@@ -77,7 +77,11 @@ function hasPendingArchiveForProject(input: {
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
const workspaceDirectory = getHostProjectSourceDirectory(input.project, input.selectedServerId);
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.selectedServerId,
|
||||
workspaceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNewWorkspaceProjectPicker({
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { workspaceTerminalsPushRoute } from "@/data/push-router";
|
||||
import { estimateTerminalViewportSize } from "@/terminal/runtime/terminal-size-cache";
|
||||
import {
|
||||
buildTerminalsQueryKey,
|
||||
canCreateWorkspaceTerminal,
|
||||
@@ -136,20 +135,14 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
// Seed the new PTY with the workspace's measured pane size so it isn't born at 80x24.
|
||||
const estimatedSize =
|
||||
estimateTerminalViewportSize({ serverId: normalizedServerId, cwd: workspaceDirectory }) ??
|
||||
undefined;
|
||||
const payload = _input?.profile
|
||||
? await client.createTerminal(workspaceDirectory, _input.profile.name, undefined, {
|
||||
command: _input.profile.command,
|
||||
args: _input.profile.args,
|
||||
workspaceId: normalizedWorkspaceId || undefined,
|
||||
size: estimatedSize,
|
||||
})
|
||||
: await client.createTerminal(workspaceDirectory, undefined, undefined, {
|
||||
workspaceId: normalizedWorkspaceId || undefined,
|
||||
size: estimatedSize,
|
||||
});
|
||||
// The daemon reports a failed spawn (e.g. a profile command that isn't
|
||||
// installed) via payload.error with a null terminal. Surface it instead
|
||||
|
||||
@@ -62,7 +62,7 @@ import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { SplitContainer } from "@/components/split-container";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
|
||||
import { WorkspaceActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceGitActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
@@ -377,9 +377,6 @@ function getFallbackTabOptionDescription(
|
||||
if (tab.target.kind === "browser") {
|
||||
return labels.browser;
|
||||
}
|
||||
if (tab.target.kind === "provider_subagent") {
|
||||
return labels.agent;
|
||||
}
|
||||
return tab.target.path;
|
||||
}
|
||||
|
||||
@@ -3279,56 +3276,56 @@ function WorkspaceScreenContent({
|
||||
hideLabels
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && workspaceDirectory ? (
|
||||
{!isMobile && isGitCheckout ? (
|
||||
<>
|
||||
<WorkspaceActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={workspaceDirectory}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
{isGitCheckout ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
testID="workspace-explorer-toggle"
|
||||
onPress={handleToggleExplorer}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={explorerToggleLabel}
|
||||
accessibilityState={explorerToggleAccessibilityState}
|
||||
style={explorerToggleStyle}
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const active = isExplorerOpen || hovered || pressed;
|
||||
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
|
||||
return (
|
||||
<>
|
||||
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
|
||||
{workspaceDescriptor?.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspaceDescriptor.diffStat.additions}
|
||||
deletions={workspaceDescriptor.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
testID="workspace-explorer-toggle-tooltip"
|
||||
side="left"
|
||||
align="center"
|
||||
offset={8}
|
||||
>
|
||||
<View style={styles.explorerTooltipRow}>
|
||||
<Text style={styles.explorerTooltipText}>
|
||||
{t("workspace.tabs.explorer.toggle")}
|
||||
</Text>
|
||||
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{workspaceDirectory ? (
|
||||
<WorkspaceGitActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={workspaceDirectory}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
testID="workspace-explorer-toggle"
|
||||
onPress={handleToggleExplorer}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={explorerToggleLabel}
|
||||
accessibilityState={explorerToggleAccessibilityState}
|
||||
style={explorerToggleStyle}
|
||||
>
|
||||
{({ hovered, pressed }) => {
|
||||
const active = isExplorerOpen || hovered || pressed;
|
||||
const colorMapping = active ? foregroundColorMapping : mutedColorMapping;
|
||||
return (
|
||||
<>
|
||||
<ThemedSourceControlPanelIcon size={16} uniProps={colorMapping} />
|
||||
{workspaceDescriptor?.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspaceDescriptor.diffStat.additions}
|
||||
deletions={workspaceDescriptor.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
testID="workspace-explorer-toggle-tooltip"
|
||||
side="left"
|
||||
align="center"
|
||||
offset={8}
|
||||
>
|
||||
<View style={styles.explorerTooltipRow}>
|
||||
<Text style={styles.explorerTooltipText}>
|
||||
{t("workspace.tabs.explorer.toggle")}
|
||||
</Text>
|
||||
<Shortcut keys={EXPLORER_TOGGLE_KEYS} style={styles.explorerTooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
{!isMobile && !isGitCheckout ? (
|
||||
|
||||
@@ -138,9 +138,6 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "setup") {
|
||||
return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`;
|
||||
}
|
||||
if (tab.target.kind === "provider_subagent") {
|
||||
return `workspace-provider-subagent-close-${tab.target.subagentId}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface WorkspaceDraftTabSetup {
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "browser"; browserId: string }
|
||||
| WorkspaceFileTabTarget
|
||||
@@ -509,17 +508,6 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
|
||||
if (kind === "agent" && typeof raw.agentId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
|
||||
}
|
||||
if (
|
||||
kind === "provider_subagent" &&
|
||||
typeof raw.parentAgentId === "string" &&
|
||||
typeof raw.subagentId === "string"
|
||||
) {
|
||||
return normalizeWorkspaceTabTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: raw.parentAgentId,
|
||||
subagentId: raw.subagentId,
|
||||
});
|
||||
}
|
||||
if (kind === "terminal" && typeof raw.terminalId === "string") {
|
||||
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
|
||||
}
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { providerSubagentKey, useProviderSubagentStore } from "./provider-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const PARENT_ID = "parent-1";
|
||||
const SUBAGENT_ID = "child-1";
|
||||
|
||||
afterEach(() => {
|
||||
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
|
||||
});
|
||||
|
||||
describe("provider subagent client store", () => {
|
||||
test("builds a shared stream model from ordered provider updates", () => {
|
||||
const subagents = useProviderSubagentStore.getState();
|
||||
subagents.applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Explore",
|
||||
description: "Inspect the repository",
|
||||
status: "running",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
subagents.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-1",
|
||||
seq: 2,
|
||||
timestamp: "2026-07-12T10:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "New live output." },
|
||||
});
|
||||
subagents.replaceTimeline(SERVER_ID, {
|
||||
requestId: "history-1",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
hasOlder: false,
|
||||
hasNewer: true,
|
||||
rows: [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Older history." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
const liveTimeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
subagents.applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Explore",
|
||||
description: "Inspect the repository",
|
||||
status: "running",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:01.500Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)),
|
||||
).toBe(liveTimeline);
|
||||
subagents.applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Explore",
|
||||
description: "Inspect the repository",
|
||||
status: "completed",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
|
||||
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||
const state = useProviderSubagentStore.getState();
|
||||
expect(state.descriptors.get(key)?.status).toBe("completed");
|
||||
expect(state.timelines.get(key)?.head).toEqual([]);
|
||||
expect(state.timelines.get(key)?.tail).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant_message",
|
||||
text: "Older history.New live output.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("removes timelines for children no longer returned by the provider", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Removed child output." },
|
||||
});
|
||||
|
||||
store.replaceList(SERVER_ID, PARENT_ID, []);
|
||||
|
||||
expect(
|
||||
useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.has(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("applies terminal list status to a timeline received before its descriptor", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Restored output." },
|
||||
});
|
||||
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||
expect(useProviderSubagentStore.getState().timelines.get(key)?.head).not.toEqual([]);
|
||||
|
||||
store.replaceList(SERVER_ID, PARENT_ID, [
|
||||
{
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Restored child",
|
||||
description: null,
|
||||
status: "completed",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
]);
|
||||
|
||||
const timeline = useProviderSubagentStore.getState().timelines.get(key);
|
||||
expect(timeline?.head).toEqual([]);
|
||||
expect(timeline?.tail).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Restored output." }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps late timeline rows terminal after the descriptor completes", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: SUBAGENT_ID,
|
||||
parentAgentId: PARENT_ID,
|
||||
provider: "codex",
|
||||
title: "Restored child",
|
||||
description: null,
|
||||
status: "completed",
|
||||
createdAt: "2026-07-12T10:00:00.000Z",
|
||||
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Late restored output." },
|
||||
});
|
||||
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect(timeline?.head).toEqual([]);
|
||||
expect(timeline?.tail).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Late restored output." }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("merges bounded older pages and tracks whether more history remains", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "tail-page",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 2, maxSeq: 2, nextSeq: 3 },
|
||||
hasOlder: true,
|
||||
hasNewer: false,
|
||||
rows: [
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-07-12T10:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "Recent output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "older-page",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "before",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
|
||||
hasOlder: false,
|
||||
hasNewer: true,
|
||||
rows: [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Older output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect(timeline?.hasOlder).toBe(false);
|
||||
expect([...timeline!.rows.keys()]).toEqual([2, 1]);
|
||||
expect(timeline?.head).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Older output.Recent output." }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores delayed live updates from a stale timeline epoch", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "current-page",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-current",
|
||||
reset: true,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 2, maxSeq: 2, nextSeq: 3 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
rows: [
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-07-12T10:00:02.000Z",
|
||||
item: { type: "assistant_message", text: "Current output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
kind: "timeline",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
epoch: "epoch-stale",
|
||||
seq: 3,
|
||||
timestamp: "2026-07-12T10:00:03.000Z",
|
||||
item: { type: "assistant_message", text: "Stale output." },
|
||||
});
|
||||
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect(timeline?.epoch).toBe("epoch-current");
|
||||
expect([...timeline!.rows.keys()]).toEqual([2]);
|
||||
expect(timeline?.head).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Current output." }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("replaces cached rows with an authoritative tail page after a reconnect gap", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "old-tail",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 500, nextSeq: 501 },
|
||||
hasOlder: true,
|
||||
hasNewer: false,
|
||||
rows: [
|
||||
{
|
||||
seq: 100,
|
||||
timestamp: "2026-07-12T10:00:00.000Z",
|
||||
item: { type: "assistant_message", text: "Old cached output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
store.replaceTimeline(SERVER_ID, {
|
||||
requestId: "reconnect-tail",
|
||||
parentAgentId: PARENT_ID,
|
||||
subagentId: SUBAGENT_ID,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 500, nextSeq: 501 },
|
||||
hasOlder: true,
|
||||
hasNewer: false,
|
||||
rows: [
|
||||
{
|
||||
seq: 401,
|
||||
timestamp: "2026-07-12T10:00:01.000Z",
|
||||
item: { type: "assistant_message", text: "Current tail output." },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
|
||||
const timeline = useProviderSubagentStore
|
||||
.getState()
|
||||
.timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID));
|
||||
expect([...timeline!.rows.keys()]).toEqual([401]);
|
||||
expect(timeline?.head).toEqual([
|
||||
expect.objectContaining({ kind: "assistant_message", text: "Current tail output." }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
import type {
|
||||
AgentStreamEventPayload,
|
||||
ProviderSubagentDescriptorPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { create } from "zustand";
|
||||
import { applyStreamEvent } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
|
||||
type ProviderSubagentTimelineItem = Extract<
|
||||
Extract<SessionOutboundMessage, { type: "agent.provider_subagents.update" }>["payload"],
|
||||
{ kind: "timeline" }
|
||||
>["item"];
|
||||
|
||||
interface ProviderSubagentTimelineRow {
|
||||
provider: ProviderSubagentDescriptorPayload["provider"];
|
||||
item: ProviderSubagentTimelineItem;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ProviderSubagentTimelineState {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
epoch: string | null;
|
||||
lastSeq: number;
|
||||
hasOlder: boolean;
|
||||
rows: Map<number, ProviderSubagentTimelineRow>;
|
||||
}
|
||||
|
||||
interface ProviderSubagentState {
|
||||
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
|
||||
timelines: Map<string, ProviderSubagentTimelineState>;
|
||||
replaceList(
|
||||
serverId: string,
|
||||
parentAgentId: string,
|
||||
subagents: ProviderSubagentDescriptorPayload[],
|
||||
): void;
|
||||
applyUpdate(
|
||||
serverId: string,
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.provider_subagents.update" }
|
||||
>["payload"],
|
||||
): void;
|
||||
replaceTimeline(
|
||||
serverId: string,
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.provider_subagents.timeline.get.response" }
|
||||
>["payload"],
|
||||
): void;
|
||||
}
|
||||
|
||||
export function providerSubagentKey(
|
||||
serverId: string,
|
||||
parentAgentId: string,
|
||||
subagentId: string,
|
||||
): string {
|
||||
return `${serverId}\0${parentAgentId}\0${subagentId}`;
|
||||
}
|
||||
|
||||
export function providerSubagentLifecycleStatus(
|
||||
status: ProviderSubagentDescriptorPayload["status"],
|
||||
): AgentLifecycleStatus {
|
||||
if (status === "running") return "running";
|
||||
if (status === "failed") return "error";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
type ProviderSubagentListClient = Pick<DaemonClient, "listProviderSubagents">;
|
||||
|
||||
const pendingListRequests = new WeakMap<ProviderSubagentListClient, Map<string, Promise<void>>>();
|
||||
|
||||
export function refreshProviderSubagents(
|
||||
client: ProviderSubagentListClient,
|
||||
serverId: string,
|
||||
parentAgentId: string,
|
||||
): Promise<void> {
|
||||
const requestKey = `${serverId}\0${parentAgentId}`;
|
||||
let clientRequests = pendingListRequests.get(client);
|
||||
if (!clientRequests) {
|
||||
clientRequests = new Map();
|
||||
pendingListRequests.set(client, clientRequests);
|
||||
}
|
||||
const pending = clientRequests.get(requestKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = client
|
||||
.listProviderSubagents(parentAgentId)
|
||||
.then((payload) => {
|
||||
useProviderSubagentStore.getState().replaceList(serverId, parentAgentId, payload.subagents);
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
clientRequests?.delete(requestKey);
|
||||
});
|
||||
clientRequests.set(requestKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function parentPrefix(serverId: string, parentAgentId: string): string {
|
||||
return `${serverId}\0${parentAgentId}\0`;
|
||||
}
|
||||
|
||||
const EMPTY_TIMELINE: ProviderSubagentTimelineState = {
|
||||
tail: [],
|
||||
head: [],
|
||||
epoch: null,
|
||||
lastSeq: 0,
|
||||
hasOlder: false,
|
||||
rows: new Map(),
|
||||
};
|
||||
|
||||
function providerSubagentTerminalEvent(
|
||||
subagent: ProviderSubagentDescriptorPayload,
|
||||
): AgentStreamEventPayload | null {
|
||||
if (subagent.status === "running") {
|
||||
return null;
|
||||
}
|
||||
if (subagent.status === "failed") {
|
||||
return { type: "turn_failed", provider: subagent.provider, error: "Subagent failed" };
|
||||
}
|
||||
if (subagent.status === "canceled") {
|
||||
return { type: "turn_canceled", provider: subagent.provider, reason: "canceled" };
|
||||
}
|
||||
return { type: "turn_completed", provider: subagent.provider };
|
||||
}
|
||||
|
||||
function buildTimelineState(
|
||||
rows: ProviderSubagentTimelineState["rows"],
|
||||
epoch: string | null,
|
||||
descriptor?: ProviderSubagentDescriptorPayload,
|
||||
hasOlder = false,
|
||||
): ProviderSubagentTimelineState {
|
||||
let timeline = { tail: [] as StreamItem[], head: [] as StreamItem[] };
|
||||
for (const [, row] of [...rows].sort(([left], [right]) => left - right)) {
|
||||
timeline = applyStreamEvent({
|
||||
...timeline,
|
||||
event: { type: "timeline", provider: row.provider, item: row.item },
|
||||
timestamp: new Date(row.timestamp),
|
||||
});
|
||||
}
|
||||
const terminalEvent = descriptor ? providerSubagentTerminalEvent(descriptor) : null;
|
||||
if (terminalEvent && descriptor) {
|
||||
timeline = applyStreamEvent({
|
||||
...timeline,
|
||||
event: terminalEvent,
|
||||
timestamp: new Date(descriptor.updatedAt),
|
||||
});
|
||||
}
|
||||
return {
|
||||
...timeline,
|
||||
epoch,
|
||||
lastSeq: rows.size ? Math.max(...rows.keys()) : 0,
|
||||
hasOlder,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTimelineResponseRows(
|
||||
existing: ProviderSubagentTimelineState | undefined,
|
||||
payload: Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.provider_subagents.timeline.get.response" }
|
||||
>["payload"],
|
||||
provider: ProviderSubagentDescriptorPayload["provider"],
|
||||
): ProviderSubagentTimelineState["rows"] {
|
||||
const rows = new Map<number, ProviderSubagentTimelineRow>();
|
||||
for (const row of payload.rows) {
|
||||
rows.set(row.seq, { provider, item: row.item, timestamp: row.timestamp });
|
||||
}
|
||||
if (payload.reset || existing?.epoch !== payload.epoch) {
|
||||
return rows;
|
||||
}
|
||||
if (payload.direction !== "tail") {
|
||||
return new Map([...existing.rows, ...rows]);
|
||||
}
|
||||
|
||||
let nextSeq = payload.rows.length
|
||||
? Math.max(...payload.rows.map((row) => row.seq)) + 1
|
||||
: payload.window.maxSeq + 1;
|
||||
for (const [seq, row] of [...existing.rows].sort(([left], [right]) => left - right)) {
|
||||
if (seq < nextSeq) continue;
|
||||
if (seq !== nextSeq) break;
|
||||
rows.set(seq, row);
|
||||
nextSeq += 1;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const useProviderSubagentStore = create<ProviderSubagentState>((set) => ({
|
||||
descriptors: new Map(),
|
||||
timelines: new Map(),
|
||||
replaceList(serverId, parentAgentId, subagents) {
|
||||
set((state) => {
|
||||
const prefix = parentPrefix(serverId, parentAgentId);
|
||||
const descriptors = new Map(
|
||||
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
|
||||
);
|
||||
for (const subagent of subagents) {
|
||||
descriptors.set(providerSubagentKey(serverId, parentAgentId, subagent.id), subagent);
|
||||
}
|
||||
const retainedKeys = new Set(descriptors.keys());
|
||||
const timelines = new Map(
|
||||
[...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)),
|
||||
);
|
||||
for (const subagent of subagents) {
|
||||
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
|
||||
const current = timelines.get(key);
|
||||
const previous = state.descriptors.get(key);
|
||||
if (current && previous?.status !== subagent.status) {
|
||||
timelines.set(
|
||||
key,
|
||||
buildTimelineState(current.rows, current.epoch, subagent, current.hasOlder),
|
||||
);
|
||||
}
|
||||
}
|
||||
return { descriptors, timelines };
|
||||
});
|
||||
},
|
||||
applyUpdate(serverId, payload) {
|
||||
set((state) => {
|
||||
if (payload.kind === "upsert") {
|
||||
const key = providerSubagentKey(
|
||||
serverId,
|
||||
payload.subagent.parentAgentId,
|
||||
payload.subagent.id,
|
||||
);
|
||||
const descriptors = new Map(state.descriptors);
|
||||
const previous = descriptors.get(key);
|
||||
descriptors.set(key, payload.subagent);
|
||||
let timelines = state.timelines;
|
||||
const current = state.timelines.get(key);
|
||||
if (current && previous?.status !== payload.subagent.status) {
|
||||
timelines = new Map(state.timelines);
|
||||
timelines.set(
|
||||
key,
|
||||
buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder),
|
||||
);
|
||||
}
|
||||
return { descriptors, timelines };
|
||||
}
|
||||
if (payload.kind === "remove") {
|
||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||
const descriptors = new Map(state.descriptors);
|
||||
const timelines = new Map(state.timelines);
|
||||
descriptors.delete(key);
|
||||
timelines.delete(key);
|
||||
return { descriptors, timelines };
|
||||
}
|
||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||
const existing = state.timelines.get(key);
|
||||
if (existing?.epoch && existing.epoch !== payload.epoch) {
|
||||
return state;
|
||||
}
|
||||
const current = existing ?? EMPTY_TIMELINE;
|
||||
if (payload.seq <= current.lastSeq) {
|
||||
return state;
|
||||
}
|
||||
const rows = new Map(current.rows);
|
||||
rows.set(payload.seq, {
|
||||
provider: payload.provider,
|
||||
item: payload.item,
|
||||
timestamp: payload.timestamp,
|
||||
});
|
||||
const descriptor = state.descriptors.get(key);
|
||||
const next =
|
||||
descriptor && descriptor.status !== "running"
|
||||
? buildTimelineState(rows, payload.epoch, descriptor, current.hasOlder)
|
||||
: applyStreamEvent({
|
||||
tail: current.tail,
|
||||
head: current.head,
|
||||
event: { type: "timeline", provider: payload.provider, item: payload.item },
|
||||
timestamp: new Date(payload.timestamp),
|
||||
});
|
||||
const timelines = new Map(state.timelines);
|
||||
timelines.set(key, {
|
||||
...next,
|
||||
epoch: payload.epoch,
|
||||
lastSeq: payload.seq,
|
||||
hasOlder: current.hasOlder,
|
||||
rows,
|
||||
});
|
||||
return { timelines };
|
||||
});
|
||||
},
|
||||
replaceTimeline(serverId, payload) {
|
||||
const provider = payload.provider;
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
set((state) => {
|
||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||
const existing = state.timelines.get(key);
|
||||
const rows = buildTimelineResponseRows(existing, payload, provider);
|
||||
const descriptor = state.descriptors.get(key);
|
||||
const timelines = new Map(state.timelines);
|
||||
timelines.set(key, buildTimelineState(rows, payload.epoch, descriptor, payload.hasOlder));
|
||||
return { timelines };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select";
|
||||
import { useProviderSubagentStore } from "./provider-store";
|
||||
import { selectSubagentsForParent } from "./select";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
@@ -59,37 +58,9 @@ function setAgents(agents: Agent[]): void {
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
|
||||
});
|
||||
|
||||
describe("selectSubagentsForParent", () => {
|
||||
it("hides cached provider children when the host does not support them", () => {
|
||||
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
|
||||
kind: "upsert",
|
||||
subagent: {
|
||||
id: "provider-child",
|
||||
parentAgentId: "parent-a",
|
||||
provider: "codex",
|
||||
title: "Provider child",
|
||||
description: null,
|
||||
status: "completed",
|
||||
createdAt: "2026-03-08T10:01:00.000Z",
|
||||
updatedAt: "2026-03-08T10:02:00.000Z",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
});
|
||||
const params = { serverId: SERVER_ID, parentAgentId: "parent-a" };
|
||||
|
||||
expect(
|
||||
selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, false),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, true).map(
|
||||
(row) => row.id,
|
||||
),
|
||||
).toEqual(["provider-child"]);
|
||||
});
|
||||
|
||||
it("returns only non-archived children for the requested parent", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent-a" }),
|
||||
@@ -223,7 +194,6 @@ describe("selectSubagentsForParent", () => {
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
kind: "paseo",
|
||||
id: "child",
|
||||
provider: "claude",
|
||||
title: "Review child",
|
||||
@@ -235,7 +205,6 @@ describe("selectSubagentsForParent", () => {
|
||||
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
|
||||
"createdAt",
|
||||
"id",
|
||||
"kind",
|
||||
"provider",
|
||||
"requiresAttention",
|
||||
"status",
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store";
|
||||
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
|
||||
export interface PaseoSubagentRow {
|
||||
kind: "paseo";
|
||||
export interface SubagentRow {
|
||||
id: Agent["id"];
|
||||
provider: Agent["provider"];
|
||||
title: Agent["title"];
|
||||
@@ -16,21 +12,7 @@ export interface PaseoSubagentRow {
|
||||
createdAt: Agent["createdAt"];
|
||||
}
|
||||
|
||||
export interface ProviderSubagentRow {
|
||||
kind: "provider";
|
||||
id: string;
|
||||
parentAgentId: string;
|
||||
provider: ProviderSubagentDescriptorPayload["provider"];
|
||||
title: string | null;
|
||||
status: ProviderSubagentDescriptorPayload["status"];
|
||||
requiresAttention: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow;
|
||||
|
||||
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
|
||||
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
|
||||
|
||||
interface SelectSubagentsParams {
|
||||
serverId: string;
|
||||
@@ -38,11 +20,9 @@ interface SelectSubagentsParams {
|
||||
}
|
||||
|
||||
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
|
||||
const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
|
||||
|
||||
function toSubagentRow(agent: Agent): SubagentRow {
|
||||
return {
|
||||
kind: "paseo",
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
title: agent.title,
|
||||
@@ -82,59 +62,11 @@ export function selectSubagentsForParent(
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function selectProviderSubagentsForParent(
|
||||
state: ProviderSubagentStoreSnapshot,
|
||||
params: SelectSubagentsParams,
|
||||
supported: boolean,
|
||||
): ProviderSubagentRow[] {
|
||||
if (!supported) return EMPTY_PROVIDER_SUBAGENT_ROWS;
|
||||
const rows: ProviderSubagentRow[] = [];
|
||||
const prefix = `${params.serverId}\0${params.parentAgentId}\0`;
|
||||
for (const [key, subagent] of state.descriptors) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
rows.push({
|
||||
kind: "provider",
|
||||
id: subagent.id,
|
||||
parentAgentId: subagent.parentAgentId,
|
||||
provider: subagent.provider,
|
||||
title: subagent.title ?? subagent.description,
|
||||
status: subagent.status,
|
||||
requiresAttention: subagent.status === "failed",
|
||||
createdAt: new Date(subagent.createdAt),
|
||||
});
|
||||
}
|
||||
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
|
||||
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
|
||||
const paseoRows = useStoreWithEqualityFn(
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
|
||||
equal,
|
||||
);
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
|
||||
);
|
||||
const providerRows = useStoreWithEqualityFn(
|
||||
useProviderSubagentStore,
|
||||
(state) => selectProviderSubagentsForParent(state, params, supported),
|
||||
equal,
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !supported) return;
|
||||
void refreshProviderSubagents(client, params.serverId, params.parentAgentId).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}, [client, params.parentAgentId, params.serverId, supported]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (providerRows.length === 0) return paseoRows;
|
||||
const rows = [...paseoRows, ...providerRows];
|
||||
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
|
||||
return rows;
|
||||
}, [paseoRows, providerRows]);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PaseoSubagentRow, SubagentRow } from "./select";
|
||||
import type { SubagentRow } from "./select";
|
||||
import {
|
||||
buildSubagentRowPresentationData,
|
||||
formatHeaderLabel,
|
||||
resolveRowLabel,
|
||||
} from "./track-presentation";
|
||||
|
||||
function row(
|
||||
overrides: Partial<PaseoSubagentRow> & Pick<PaseoSubagentRow, "id">,
|
||||
): PaseoSubagentRow {
|
||||
function row(overrides: Partial<SubagentRow> & Pick<SubagentRow, "id">): SubagentRow {
|
||||
return {
|
||||
kind: "paseo",
|
||||
id: overrides.id,
|
||||
provider: overrides.provider ?? "codex",
|
||||
title: overrides.title ?? `Agent ${overrides.id}`,
|
||||
@@ -94,9 +91,7 @@ describe("resolveRowLabel", () => {
|
||||
|
||||
describe("buildSubagentRowPresentationData", () => {
|
||||
it("namespaces the key with a subagent prefix", () => {
|
||||
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe(
|
||||
"paseo_subagent_child-a",
|
||||
);
|
||||
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe("subagent_child-a");
|
||||
});
|
||||
|
||||
it("marks the row ready when the title resolves to a real label", () => {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import type { SubagentRow } from "./select";
|
||||
import { providerSubagentLifecycleStatus } from "./provider-store";
|
||||
|
||||
function presentationStatus(row: SubagentRow) {
|
||||
if (row.kind === "paseo") return row.status;
|
||||
return providerSubagentLifecycleStatus(row.status);
|
||||
}
|
||||
|
||||
export interface SubagentRowPresentationData {
|
||||
key: string;
|
||||
@@ -19,15 +13,14 @@ export interface SubagentRowPresentationData {
|
||||
|
||||
export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData {
|
||||
const label = resolveRowLabel(row.title);
|
||||
const status = presentationStatus(row);
|
||||
return {
|
||||
key: `${row.kind}_subagent_${row.id}`,
|
||||
key: `subagent_${row.id}`,
|
||||
kind: "agent",
|
||||
label: label ?? "",
|
||||
subtitle: "",
|
||||
titleState: label ? "ready" : "loading",
|
||||
statusBucket: deriveSidebarStateBucket({
|
||||
status,
|
||||
status: row.status,
|
||||
requiresAttention: false,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -28,7 +28,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
export interface SubagentsTrackProps {
|
||||
rows: SubagentRow[];
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
@@ -45,7 +44,6 @@ function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
||||
export function SubagentsTrack({
|
||||
rows,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackProps): ReactElement | null {
|
||||
@@ -107,7 +105,6 @@ export function SubagentsTrack({
|
||||
key={row.id}
|
||||
row={row}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
@@ -123,7 +120,6 @@ export function SubagentsTrack({
|
||||
interface SubagentsTrackRowProps {
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
@@ -131,7 +127,6 @@ interface SubagentsTrackRowProps {
|
||||
function SubagentsTrackRow({
|
||||
row,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
@@ -142,12 +137,8 @@ function SubagentsTrackRow({
|
||||
const displayLabel =
|
||||
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
||||
const handlePress = useCallback(() => {
|
||||
if (row.kind === "provider") {
|
||||
onOpenProviderSubagent(row.parentAgentId, row.id);
|
||||
} else {
|
||||
onOpenSubagent(row.id);
|
||||
}
|
||||
}, [onOpenProviderSubagent, onOpenSubagent, row]);
|
||||
onOpenSubagent(row.id);
|
||||
}, [onOpenSubagent, row.id]);
|
||||
const handleArchivePress = useCallback(() => {
|
||||
onArchiveSubagent(row.id);
|
||||
}, [onArchiveSubagent, row.id]);
|
||||
@@ -176,15 +167,13 @@ function SubagentsTrackRow({
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
{row.kind === "paseo" ? (
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
visible={actionsVisible}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
/>
|
||||
) : null}
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
visible={actionsVisible}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
estimateTerminalViewportSize,
|
||||
rememberTerminalViewportSize,
|
||||
resetTerminalViewportSizeCacheForTests,
|
||||
} from "./terminal-size-cache";
|
||||
|
||||
describe("terminal-size-cache", () => {
|
||||
beforeEach(() => {
|
||||
resetTerminalViewportSizeCacheForTests();
|
||||
});
|
||||
|
||||
it("returns null before any size has been measured", () => {
|
||||
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the last measured size for the same workspace", () => {
|
||||
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo", size: { rows: 55, cols: 136 } });
|
||||
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo" })).toEqual({
|
||||
rows: 55,
|
||||
cols: 136,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the most recent size from another workspace", () => {
|
||||
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo-a", size: { rows: 40, cols: 100 } });
|
||||
// No terminal has been measured in /repo-b yet — the estimate uses the global most-recent size.
|
||||
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo-b" })).toEqual({
|
||||
rows: 40,
|
||||
cols: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the same-workspace size over the global most-recent", () => {
|
||||
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo-a", size: { rows: 40, cols: 100 } });
|
||||
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo-b", size: { rows: 55, cols: 136 } });
|
||||
// /repo-a keeps its own measured size even though /repo-b was measured more recently.
|
||||
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo-a" })).toEqual({
|
||||
rows: 40,
|
||||
cols: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("keys by server + cwd so different hosts do not collide", () => {
|
||||
rememberTerminalViewportSize({ serverId: "s1", cwd: "/repo", size: { rows: 40, cols: 100 } });
|
||||
rememberTerminalViewportSize({ serverId: "s2", cwd: "/repo", size: { rows: 55, cols: 136 } });
|
||||
expect(estimateTerminalViewportSize({ serverId: "s1", cwd: "/repo" })).toEqual({
|
||||
rows: 40,
|
||||
cols: 100,
|
||||
});
|
||||
expect(estimateTerminalViewportSize({ serverId: "s2", cwd: "/repo" })).toEqual({
|
||||
rows: 55,
|
||||
cols: 136,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
export interface TerminalViewportSize {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
function cacheKey(input: { serverId: string; cwd: string }): string {
|
||||
// JSON-encode the pair so a `:` inside serverId or cwd (e.g. a Windows `C:\` path) can't
|
||||
// make two different (serverId, cwd) pairs collide onto the same key.
|
||||
return JSON.stringify([input.serverId, input.cwd]);
|
||||
}
|
||||
|
||||
const sizeByWorkspace = new Map<string, TerminalViewportSize>();
|
||||
let mostRecentSize: TerminalViewportSize | null = null;
|
||||
|
||||
/**
|
||||
* Remember the latest measured terminal viewport size for a workspace. Every terminal in a
|
||||
* workspace renders into the same pane, so this is the best estimate of the size a *new*
|
||||
* terminal in that workspace will render at — used to seed the PTY at creation time instead
|
||||
* of the daemon's 80x24 default (which otherwise shows briefly, or sticks, until the first
|
||||
* resize lands).
|
||||
*/
|
||||
export function rememberTerminalViewportSize(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
size: TerminalViewportSize;
|
||||
}): void {
|
||||
const size: TerminalViewportSize = { rows: input.size.rows, cols: input.size.cols };
|
||||
sizeByWorkspace.set(cacheKey(input), size);
|
||||
mostRecentSize = size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best estimate of the viewport size a new terminal in this workspace will render at:
|
||||
* the last measured size for the same workspace, else the most recently measured size
|
||||
* anywhere (panes are usually the same size across workspaces on one device), else null
|
||||
* when nothing has been measured yet this session — in which case the daemon keeps its
|
||||
* 80x24 default and the first resize corrects it as before.
|
||||
*/
|
||||
export function estimateTerminalViewportSize(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}): TerminalViewportSize | null {
|
||||
return sizeByWorkspace.get(cacheKey(input)) ?? mostRecentSize;
|
||||
}
|
||||
|
||||
/** Test-only: clear all remembered sizes so cases don't leak into each other. */
|
||||
export function resetTerminalViewportSizeCacheForTests(): void {
|
||||
sizeByWorkspace.clear();
|
||||
mostRecentSize = null;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "./identity";
|
||||
|
||||
describe("provider subagent tab identity", () => {
|
||||
test("normalizes and compares the parent and provider child as one tab identity", () => {
|
||||
const target = normalizeWorkspaceTabTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: " parent-a ",
|
||||
subagentId: " child-a ",
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: "parent-a",
|
||||
subagentId: "child-a",
|
||||
});
|
||||
expect(
|
||||
target &&
|
||||
workspaceTabTargetsEqual(target, {
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: "parent-a",
|
||||
subagentId: "child-a",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not collide when parent and child ids contain separators", () => {
|
||||
const first = buildDeterministicWorkspaceTabId({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: "a_b",
|
||||
subagentId: "c",
|
||||
});
|
||||
const second = buildDeterministicWorkspaceTabId({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: "a",
|
||||
subagentId: "b_c",
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
@@ -21,13 +21,6 @@ export function normalizeWorkspaceTabTarget(
|
||||
const agentId = trimNonEmpty(value.agentId);
|
||||
return agentId ? { kind: "agent", agentId } : null;
|
||||
}
|
||||
if (value.kind === "provider_subagent") {
|
||||
const parentAgentId = trimNonEmpty(value.parentAgentId);
|
||||
const subagentId = trimNonEmpty(value.subagentId);
|
||||
return parentAgentId && subagentId
|
||||
? { kind: "provider_subagent", parentAgentId, subagentId }
|
||||
: null;
|
||||
}
|
||||
if (value.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||
@@ -83,9 +76,6 @@ export function workspaceTabTargetsEqual(
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "provider_subagent" && right.kind === "provider_subagent") {
|
||||
return left.parentAgentId === right.parentAgentId && left.subagentId === right.subagentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
@@ -141,9 +131,6 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "agent") {
|
||||
return `agent_${target.agentId}`;
|
||||
}
|
||||
if (target.kind === "provider_subagent") {
|
||||
return `provider_subagent_${target.parentAgentId.length}_${target.parentAgentId}_${target.subagentId.length}_${target.subagentId}`;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("selectProjectWorkspacesToArchive", () => {
|
||||
|
||||
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
|
||||
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
|
||||
workspaceName: "feature/risky",
|
||||
worktreeName: "feature/risky",
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 2,
|
||||
diffStat: { additions: 5, deletions: 1 },
|
||||
@@ -73,7 +73,7 @@ describe("selectProjectWorkspacesToArchive", () => {
|
||||
|
||||
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
|
||||
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
|
||||
workspaceName: "feature/risky",
|
||||
worktreeName: "feature/risky",
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 2,
|
||||
diffStat: { additions: 5, deletions: 1 },
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function selectProjectWorkspacesToArchive(
|
||||
for (const workspace of workspaces) {
|
||||
if (workspace.workspaceKind === "worktree") {
|
||||
const shouldArchive = await confirmWorktreeArchive({
|
||||
workspaceName: workspace.name,
|
||||
worktreeName: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
});
|
||||
if (!shouldArchive) {
|
||||
|
||||
@@ -2,30 +2,20 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
||||
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
|
||||
if (workspaceKey) {
|
||||
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
|
||||
}
|
||||
useWorkspaceTabsStore.getState().purgeWorkspace(input);
|
||||
}
|
||||
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
|
||||
|
||||
export interface ArchiveWorkspaceInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
isDirty?: boolean | null;
|
||||
@@ -44,6 +34,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
const {
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
workspaceKind,
|
||||
name,
|
||||
isDirty,
|
||||
@@ -55,8 +46,37 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
} = input;
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
|
||||
const archiveWorkspaceRecord = useCallback(async () => {
|
||||
const archiveWorktreeRecord = useCallback(() => {
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceDirectory({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onArchiveStarted();
|
||||
void archiveWorktree({
|
||||
serverId,
|
||||
cwd: archiveDirectory,
|
||||
worktreePath: archiveDirectory,
|
||||
workspaceId,
|
||||
}).catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
|
||||
);
|
||||
});
|
||||
}, [archiveWorktree, onArchiveStarted, serverId, t, toast, workspaceDirectory, workspaceId]);
|
||||
|
||||
const archiveNonWorktreeRecord = useCallback(async () => {
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
@@ -64,18 +84,17 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
}
|
||||
onSetHiding?.(true);
|
||||
try {
|
||||
onArchiveStarted();
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: {
|
||||
serverId,
|
||||
workspaceId,
|
||||
},
|
||||
afterHide: onArchiveStarted,
|
||||
});
|
||||
purgeArchivedWorkspaceState({ serverId, workspaceId });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
|
||||
);
|
||||
} finally {
|
||||
onSetHiding?.(false);
|
||||
@@ -87,7 +106,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
if (workspaceKind === "worktree") {
|
||||
const confirmed = await confirmRiskyWorktreeArchive(
|
||||
{
|
||||
workspaceName: name,
|
||||
worktreeName: name,
|
||||
isDirty,
|
||||
aheadOfOrigin,
|
||||
diffStat,
|
||||
@@ -97,12 +116,15 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
archiveWorktreeRecord();
|
||||
return;
|
||||
}
|
||||
await archiveWorkspaceRecord();
|
||||
await archiveNonWorktreeRecord();
|
||||
})();
|
||||
}, [
|
||||
aheadOfOrigin,
|
||||
archiveWorkspaceRecord,
|
||||
archiveNonWorktreeRecord,
|
||||
archiveWorktreeRecord,
|
||||
diffStat,
|
||||
isDirty,
|
||||
name,
|
||||
|
||||
@@ -144,6 +144,23 @@ describe("archiveWorkspaceOptimistically", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("runs the after-hide hook after local state is hidden", async () => {
|
||||
const archived = workspace();
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
|
||||
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
|
||||
const afterHide = vi.fn(() => {
|
||||
expect(storedWorkspace(archived.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace: target(),
|
||||
afterHide,
|
||||
});
|
||||
|
||||
expect(afterHide).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveWorkspacesOptimistically", () => {
|
||||
@@ -166,7 +183,10 @@ describe("archiveWorkspacesOptimistically", () => {
|
||||
|
||||
const failures = await archiveWorkspacesOptimistically({
|
||||
getClient: () => client,
|
||||
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
|
||||
workspaces: [
|
||||
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
|
||||
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(failures).toHaveLength(1);
|
||||
@@ -199,10 +219,12 @@ describe("archiveWorkspacesOptimistically", () => {
|
||||
target({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: first.id,
|
||||
workspaceDirectory: first.workspaceDirectory,
|
||||
}),
|
||||
target({
|
||||
serverId: SECOND_SERVER_ID,
|
||||
workspaceId: second.id,
|
||||
workspaceDirectory: second.workspaceDirectory,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { i18n } from "@/i18n/i18next";
|
||||
export interface WorkspaceArchiveTarget {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}
|
||||
|
||||
interface WorkspaceArchiveClient {
|
||||
@@ -49,6 +50,7 @@ function hideWorkspaceOptimistically(
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return { workspace: snapshot };
|
||||
@@ -81,8 +83,10 @@ async function archiveWorkspaceOrThrow(input: {
|
||||
export async function archiveWorkspaceOptimistically(input: {
|
||||
client: WorkspaceArchiveClient;
|
||||
workspace: WorkspaceArchiveTarget;
|
||||
afterHide?: () => void;
|
||||
}): Promise<void> {
|
||||
const snapshot = hideWorkspaceOptimistically(input.workspace);
|
||||
input.afterHide?.();
|
||||
|
||||
try {
|
||||
await archiveWorkspaceOrThrow({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -27,9 +27,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.107",
|
||||
"@getpaseo/protocol": "0.1.107",
|
||||
"@getpaseo/server": "0.1.107",
|
||||
"@getpaseo/client": "0.1.105",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/server": "0.1.105",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -35,8 +35,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.107",
|
||||
"@getpaseo/relay": "0.1.107",
|
||||
"@getpaseo/protocol": "0.1.105",
|
||||
"@getpaseo/relay": "0.1.105",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -555,7 +555,6 @@ test("advertises client capabilities in hello", async () => {
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
custom_mode_icons: true,
|
||||
provider_subagents: true,
|
||||
reasoning_merge_enum: true,
|
||||
terminal_reflowable_snapshot: true,
|
||||
browser_host: {
|
||||
|
||||
@@ -490,22 +490,6 @@ export interface FetchAgentTimelineOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export type ProviderSubagentListPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.provider_subagents.list.response" }
|
||||
>["payload"];
|
||||
export type ProviderSubagentTimelinePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent.provider_subagents.timeline.get.response" }
|
||||
>["payload"];
|
||||
export interface FetchProviderSubagentTimelineOptions {
|
||||
direction?: ProviderSubagentTimelinePayload["direction"];
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
requestId?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// COMPAT(daemon-client-object-options): added in v0.1.102; remove after
|
||||
// 2026-12-29 once SDK callers have migrated to object parameters.
|
||||
function normalizeFetchAgentOptions(
|
||||
@@ -2445,65 +2429,6 @@ export class DaemonClient {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async listProviderSubagents(
|
||||
parentAgentId: string,
|
||||
options: { requestId?: string; timeout?: number } = {},
|
||||
): Promise<ProviderSubagentListPayload> {
|
||||
const requestId = this.createRequestId(options.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "agent.provider_subagents.list.request",
|
||||
parentAgentId,
|
||||
requestId,
|
||||
});
|
||||
const payload = await this.sendRequest({
|
||||
requestId,
|
||||
message,
|
||||
timeout: options.timeout,
|
||||
options: { skipQueue: true },
|
||||
select: (response) =>
|
||||
response.type === "agent.provider_subagents.list.response" &&
|
||||
response.payload.requestId === requestId
|
||||
? response.payload
|
||||
: null,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async fetchProviderSubagentTimeline(
|
||||
parentAgentId: string,
|
||||
subagentId: string,
|
||||
options: FetchProviderSubagentTimelineOptions = {},
|
||||
): Promise<ProviderSubagentTimelinePayload> {
|
||||
const requestId = this.createRequestId(options.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "agent.provider_subagents.timeline.get.request",
|
||||
parentAgentId,
|
||||
subagentId,
|
||||
requestId,
|
||||
...(options.direction ? { direction: options.direction } : {}),
|
||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
|
||||
});
|
||||
const payload = await this.sendRequest({
|
||||
requestId,
|
||||
message,
|
||||
timeout: options.timeout,
|
||||
options: { skipQueue: true },
|
||||
select: (response) =>
|
||||
response.type === "agent.provider_subagents.timeline.get.response" &&
|
||||
response.payload.requestId === requestId
|
||||
? response.payload
|
||||
: null,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async buildAgentForkContext(
|
||||
agentId: string,
|
||||
options: AgentForkContextOptions = {},
|
||||
@@ -4275,13 +4200,7 @@ export class DaemonClient {
|
||||
cwd: string,
|
||||
name?: string,
|
||||
requestId?: string,
|
||||
options?: {
|
||||
agentId?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
workspaceId?: string;
|
||||
size?: { rows: number; cols: number };
|
||||
},
|
||||
options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string },
|
||||
): Promise<CreateTerminalPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -4292,7 +4211,6 @@ export class DaemonClient {
|
||||
command: options?.command,
|
||||
args: options?.args,
|
||||
...(options?.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
|
||||
...(options?.size !== undefined ? { size: options.size } : {}),
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendCorrelatedRequest({
|
||||
@@ -4754,7 +4672,6 @@ export class DaemonClient {
|
||||
[CLIENT_CAPS.customModeIcons]: true,
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
||||
[CLIENT_CAPS.providerSubagents]: true,
|
||||
...this.config.capabilities,
|
||||
},
|
||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.107",
|
||||
"version": "0.1.105",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -11,9 +11,6 @@ export const CLIENT_CAPS = {
|
||||
// Old clients use a strict TerminalState schema and would reject the extra fields.
|
||||
// Drop the gate (always send the flags) when floor >= v0.1.88.
|
||||
terminalReflowableSnapshot: "terminal_reflowable_snapshot",
|
||||
// COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned
|
||||
// child descriptors and timelines only to clients that understand the new messages.
|
||||
providerSubagents: "provider_subagents",
|
||||
browserHost: "browser_host",
|
||||
} as const;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user