Compare commits

..
Author SHA1 Message Date
odoo-mvds 65ce4b590a fix(app): remove restored agent tabs from foreign workspaces (#7956)
A persisted explicit pin outlives archive recovery because its agent never joins the unrelated workspace directory. Drop positively foreign pins after hydration and let existing reconciliation remove only the misplaced view. Preserve unknown ownership and legitimate historical opens.

Adapts the repair proposed by kaicot in getpaseo/paseo#6901 to current main, with persisted-store and real-browser regression evidence. Refs getpaseo/paseo#6900.
2026-10-26 17:54:25 +00:00
paseo-bot[bot]andliyang 4f9d9d6de2 Keep ACP terminal output from stalling daemon requests (#7953)
* fix(acp): trim terminal output in linear time

* test(acp): reproduce terminal output stalls with a larger workload

The 128 KiB workload completes inside the regression deadline on faster
machines even with quadratic trimming. One MiB exceeds that deadline on
the old implementation while the bounded UTF-8 suffix implementation
finishes promptly.

Co-authored-by: liyang <liyang@msh.team>

---------

Co-authored-by: liyang <liyang@msh.team>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-26 17:04:42 +00:00
Mohamed Boudra 1f3266e2ba test(server): remove connected-host delivery timing assumptions (#7961)
CLI startup is not bounded by the recipient quiet window. Exercise the three delivery sources in separate windows and retain exact timeline and durable receipt assertions. Await permission-finish cleanup after dispatch acknowledgment using the integration cleanup budget.
2026-10-26 23:56:19 +07:00
Mohamed Boudra 28834a91ae fix(server): tolerate brief Windows atomic replace failures (#7955)
A receipt could remain pending after provider delivery when Windows rejected the completed-state rename with EPERM. Retry transient replace errors within a one-second budget while retaining the original file and cleanup semantics.
2026-10-26 23:13:25 +07:00
Simon Gurcke e3bbed3c73 fix(pi): queue prompts until manual compaction finishes (#7848)
* fix(pi): wait for manual compaction before sending prompts

* test(pi): simplify compaction regressions and stabilize live smoke
2026-10-26 14:02:44 +00:00
Mohamed Boudra 42d74c2546 fix(ci): retry transient Apple staple connection failures (#7933) 2026-10-26 19:20:17 +07:00
Mohamed Boudra 678fe308db chore(release): reconcile 0.13.2 metadata with main (#7931)
* docs(release): add 0.13.2 native patch notes

(cherry picked from commit 870c969fdb)

* chore(release): cut 0.13.2

(cherry picked from commit d2941157a8)

* chore(release): refresh Nix dependency hash for 0.13.2
2026-10-26 18:35:29 +07:00
Mohamed Boudra 287ca54749 fix(app): enlarge sidebar kebab tap targets on compact layouts (#7928)
On phones the project and workspace row kebabs were 24px and 18px boxes
with a 14px glyph, too small to hit reliably. Compact layouts now use a
32px box and the density-sized glyph (18px); desktop sizes are unchanged.
Negative margins keep the dots on the existing right rail and the rows at
their 44px compact height.
2026-10-26 18:12:56 +07:00
paseo-ai[bot] 902b0c7a16 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-26 08:55:29 +00:00
107 changed files with 1693 additions and 3531 deletions
+28
View File
@@ -169,6 +169,34 @@ jobs:
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: |
set -euo pipefail
export PASEO_REAL_XCRUN="$(command -v xcrun)"
stapler_bin="$(mktemp -d)"
trap 'rm -rf "$stapler_bin"' EXIT
cat > "$stapler_bin/xcrun" <<'STAPLER'
#!/bin/bash
if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then
exec "$PASEO_REAL_XCRUN" "$@"
fi
output="$(mktemp)"
trap 'rm -f "$output"' EXIT
for attempt in 1 2 3 4; do
if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then
cat "$output"
exit 0
else
status=$?
fi
cat "$output"
if [[ "$status" != 68 || "$attempt" == 4 ]] ||
! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then
exit "$status"
fi
echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2
sleep 30
done
STAPLER
chmod +x "$stapler_bin/xcrun"
export PATH="$stapler_bin:$PATH"
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 0.13.2 - 2026-10-26
### Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### Fixed
+9
View File
@@ -1,5 +1,14 @@
# 更新日志
## 0.13.2 - 2026-10-26
### 修复
- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907))
- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914))
## 0.13.1 - 2026-10-26
### 修复
+5 -44
View File
@@ -340,50 +340,11 @@ Example: adding a new enum value
### Creation ownership
Creation execution is owned by the daemon across socket lifetimes. A workspace request
can include its initial agent and prompt. The app's `workspace-creation/` module owns
chat submission presentation outside React: it reserves final IDs and a message identity,
then opens the agent target before asynchronous preparation or a daemon response.
The New workspace chat entry requires `creationLifecycle`; empty workspaces, terminal
launches, and existing-workspace agent drafts retain their own entry points.
Pending presentation never enters the directory replica as a fabricated resource.
Routes, tabs, and the sidebar retain it until the corresponding authoritative resource
has been observed. Files, terminals, scripts, and agent timeline initialization wait for
real resources. The creating agent opens in the normal agent panel and composer, showing
its submitted configuration. Until the host publishes the agent, its destination cwd is
unknown and its controls are read-only. The host publishes the agent before it starts the
initial prompt, so prompts wait in the agent's queue until creation completes
(`isAgentReadyForPrompts`). `composer/actions.ts` applies that to every prompt path: send
and review feedback queue behind earlier prompts, and send now and queue drain leave the
queue untouched. The queue drains on adoption or when the initial turn ends. The first
message uses composer submission reconciliation; creation never sends it again when the
panel mounts. Completion does not navigate, and draft cleanup checks the captured version
so it cannot erase newer input.
A failed creation goes to the existing owner of whatever the host did not create, using
the receipt's resources and `failedStage`. Creation has no failure UI of its own:
- No workspace: the app returns to New workspace with the submitted draft and checkout
selection, and shows the error the way that screen shows its own submit errors.
- Workspace but no agent: the agent tab becomes an existing-workspace agent draft holding
the prompt, in the abandoned create-flow state. Its error and Send retry are that draft's
own; the retry creates the agent in the kept workspace.
- An agent that exists or may exist, including a failed first prompt: creation completes
and the agent tab keeps the agent's own timeline and turn error. Turning it into a draft
would create a second agent on Send.
Queued follow-ups and unsent composer text belong to a composer identity, so they move
with the creation: from the form to the creating agent, back to the form or the agent draft
on failure, and to the created agent after retry (`transferComposerFollowUps`). The initial
prompt stays the form's or draft's input. The agent draft also carries the submitted
setup and context attachments. The host ignores `config.cwd` when a create request names a
workspace, so a draft's retry starts at the workspace root, not a nested fork cwd.
Adopted successful attempts release their payloads; a bounded cache of compact receipts
absorbs duplicate commands from retained forms. The SDK owns reconnect observation; the
app has no resend loop. Pending views survive navigation and pane remount, not app
process death. Resource reservations are identities, not ready workspace records.
Creation is owned by the daemon across socket lifetimes. A workspace request can include
its initial agent and prompt. Workspace readiness is published before provider startup;
the app keeps its existing disk-ready navigation and observes the remaining creation.
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
identities, not ready workspace records.
The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Fixed
- Fixed repeated Android animation exceptions for views that have already unmounted
- Fixed the Android animation run loop continuing while the app is in the background
- Fixed finished Android chat messages being remeasured during streaming updates
- Fixed bottom sheets retaining window-size listeners after closing
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-SKL7TMIuuV3imTIaQc66UDf3n6V3FB0nGSBucJ6rlA0=
sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE=
+28 -29
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.13.1",
"version": "0.13.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.1",
"version": "0.13.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -35695,7 +35695,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -35715,7 +35715,6 @@
"@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@noble/hashes": "^2.4.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native/normalize-colors": "0.86.3",
@@ -37962,12 +37961,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/server": "0.13.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38214,10 +38213,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.1",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38229,7 +38228,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.13.1",
"version": "0.13.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38473,7 +38472,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.1",
"version": "0.13.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -42092,7 +42091,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42121,15 +42120,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42139,8 +42138,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42162,7 +42161,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42189,7 +42188,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -42410,16 +42409,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.1",
"@getpaseo/highlight": "0.13.1",
"@getpaseo/plugin": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43340,7 +43339,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -44950,7 +44949,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.13.1",
"version": "0.13.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.1",
"version": "0.13.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -13,7 +13,9 @@ import {
closeImageLightbox,
pressInterruptShortcut,
expectComposerDraft,
expectComposerDisabled,
expectComposerEditable,
expectAttachButtonDisabled,
fillComposerDraft,
dropFileOnComposer,
sendDraftToQueue,
@@ -266,9 +268,7 @@ test.describe("Composer attachments", () => {
}
});
test("composer and attachments stay available while new workspace agent is being created", async ({
page,
}) => {
test("composer is locked while new workspace agent is being created", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
@@ -298,9 +298,8 @@ test.describe("Composer attachments", () => {
await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus();
await expectComposerEditable(page);
await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expectComposerDisabled(page);
await expectAttachButtonDisabled(page);
agentCreatedDelay.release();
@@ -1,12 +1,4 @@
import { expectPickerSelected } from "../support/helpers/new-workspace";
import {
hideCreationLifecycle,
holdWorkspaceProvisioning,
reportUnknownPromptOutcome,
} from "../support/helpers/creation";
import type { createCreationScenario } from "../support/helpers/creation";
import { expectComposerMode, expectComposerModel } from "../support/helpers/agent-profiles";
import { expectChatHistoryAttachment } from "../support/helpers/assistant-fork";
import { expect } from "../support/fixtures";
import { test } from "../support/creation-fixtures";
@@ -113,38 +105,26 @@ test("new workspace navigation and optimistic prompt precede agent initializatio
await creation.expectAgentCount(1);
});
for (const entry of [
for (const scenario of [
{ suffix: "", prepare: async () => {} },
{
name: "an agent draft",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openAgentDraft(),
button: "Send message",
expectWorkspace: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
},
{
name: "a new workspace",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openWorkspaceForm("local"),
button: "Create",
expectWorkspace: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.expectOneCreatedWorkspace(),
suffix: " after remount",
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.evictAndReturnToDraft(),
},
]) {
test(`a failed agent start in ${entry.name} keeps the draft, its error and its retry`, async ({
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
creation,
startup,
page,
}, testInfo) => {
const prompt = "Retry this agent after its startup fails.";
await entry.open(creation);
await creation.submitPrompt(prompt, entry.button);
}) => {
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectFailedAgentDraft(prompt);
await entry.expectWorkspace(creation);
await page.screenshot({ path: testInfo.outputPath("failed-agent-draft.png") });
await creation.resendFailedDraft();
await creation.expectPromptVisible(prompt);
await entry.expectWorkspace(creation);
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await scenario.prepare(creation);
await creation.submitPrompt("Retry this workspace and agent together.");
await creation.expectAgentCount(1);
});
}
@@ -191,356 +171,3 @@ for (const entry of [
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
});
}
for (const isolation of ["local", "worktree"] as const) {
test(`a held ${isolation} workspace opens the normal agent panel and queues a follow-up`, async ({
creation,
page,
}, testInfo) => {
const prompt = "emit 1 coalesced agent stream updates for the first workspace prompt.";
const followUp = "emit 1 coalesced agent stream updates for the queued follow-up.";
const draft = "Keep this unsent draft through adoption.";
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm(isolation);
await creation.submitPrompt(prompt, "Create");
const request = await provisioning.waitForRequest();
await test.step("the final agent panel paints before any host response", async () => {
expect(request.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/);
expect(request.agent!.agentId).toMatch(/^[a-f0-9-]{36}$/);
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
await expect(
page.getByTestId(`workspace-tab-agent_${request.agent!.agentId}`).filter({ visible: true }),
).toBeVisible();
await creation.expectPromptVisible(prompt);
await creation.expectNormalAgentComposer();
await expectComposerModel(page, "Ten second stream");
await expectComposerMode(page, "Load test");
});
await test.step("a follow-up waits in the queue instead of reaching the agent", async () => {
await creation.queueFollowUp(followUp);
await creation.typeComposerDraft(draft);
expect(provisioning.agentPrompts()).toBe(0);
await page.screenshot({ path: testInfo.outputPath("held-normal-panel.png") });
});
await test.step("adoption keeps the same agent, draft and queue order", async () => {
provisioning.release();
await creation.expectOneCreatedWorkspace();
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
await creation.expectUserMessagesInOrder([prompt, followUp]);
expect(provisioning.agentPrompts()).toBe(1);
await creation.expectComposerDraft(draft);
await creation.expectTimelineSettled();
await page.screenshot({ path: testInfo.outputPath("adopted-normal-panel.png") });
});
});
}
test("a held workspace on compact shows the final agent prompt and composer", async ({
creation,
page,
}, testInfo) => {
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("worktree");
await page.setViewportSize({ width: 390, height: 844 });
await creation.submitPrompt("Show my prompt before workspace provisioning.", "Create");
const request = await provisioning.waitForRequest();
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
await creation.expectPromptVisible("Show my prompt before workspace provisioning.");
await creation.expectNormalAgentComposer();
await creation.expectAgentCannotBeStoppedYet();
await page.screenshot({ path: testInfo.outputPath("compact-held.png") });
provisioning.release();
await creation.expectOneCreatedWorkspace();
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
await creation.expectInitialPromptOnce("Show my prompt before workspace provisioning.");
});
test("workspace creation recovers a lost response without replaying the first prompt", async ({
creation,
workspacePromptRetry,
}) => {
await creation.openWorkspaceForm("worktree");
workspacePromptRetry.holdAcknowledgement();
await creation.submitPrompt("Recover this workspace prompt once.", "Create");
await workspacePromptRetry.waitForDeliveredPrompt();
await creation.expectPromptVisible("Recover this workspace prompt once.");
await creation.evictAndReturnToDraft();
await workspacePromptRetry.disconnectAndReconnect();
await workspacePromptRetry.expectSameAgentAndMessage();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("a pending workspace survives leaving and remounting before any daemon event", async ({
creation,
page,
}) => {
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("worktree");
await creation.submitPrompt("Keep this pending workspace across remount.", "Create");
await provisioning.waitForRequest();
await creation.expectPromptVisible("Keep this pending workspace across remount.");
await creation.evictAndReturnToDraft();
await creation.expectPromptVisible("Keep this pending workspace across remount.");
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("a failed workspace returns to New workspace with the prompt and its error", async ({
creation,
provisioningFailure,
page,
}, testInfo) => {
const prompt = "Keep my prompt when the workspace fails.";
await provisioningFailure.arm();
await creation.openWorkspaceForm("local");
await creation.submitRepeatedly("Create", prompt);
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
await page.screenshot({ path: testInfo.outputPath("failed-workspace-returned.png") });
});
test("a failed workspace preserves its non-default branch through resubmission", async ({
creation,
provisioningFailure,
page,
}) => {
const prompt = "Keep the selected worktree base when provisioning fails.";
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("worktree");
await creation.selectWorktreeBranch("recovery-base");
await provisioningFailure.arm();
await creation.submitPrompt(prompt, "Create");
const first = await provisioning.waitForRequest();
expect(first.source).toMatchObject({
kind: "worktree",
action: "branch-off",
refName: "refs/heads/recovery-base",
});
provisioning.release();
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
await expectPickerSelected(page, "recovery-base");
await creation.resubmitWorkspace();
const second = await provisioning.waitForRequests(2);
expect(second.source).toMatchObject({
kind: "worktree",
action: "branch-off",
refName: "refs/heads/recovery-base",
});
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
});
test("a failed workspace keeps its queued follow-up and unsent text through resubmission", async ({
creation,
provisioningFailure,
page,
}, testInfo) => {
const prompt = "emit 1 coalesced agent stream updates for the resubmitted workspace.";
const followUp = "emit 1 coalesced agent stream updates for the resubmitted follow-up.";
const draft = "Keep this unsent text through the failed workspace.";
await provisioningFailure.arm();
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("local");
await creation.submitPrompt(prompt, "Create");
await provisioning.waitForRequest();
await creation.queueFollowUp(followUp);
await creation.typeComposerDraft(draft);
provisioning.release();
await test.step("New workspace shows the prompt, its error and the queued follow-up", async () => {
await creation.expectReturnedToNewWorkspace(
prompt,
"Provisioning outcome is uncertain for test",
);
await creation.expectQueuedFollowUp(followUp);
await page.screenshot({ path: testInfo.outputPath("failed-workspace-follow-up.png") });
});
await test.step("resubmitting delivers the prompt, then the follow-up, once", async () => {
await creation.resubmitWorkspace();
await provisioning.waitForRequests(2);
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectUserMessagesInOrder([prompt, followUp]);
await creation.expectComposerDraft(draft);
await page.screenshot({ path: testInfo.outputPath("resubmitted-workspace-follow-up.png") });
});
});
test("a failed agent start keeps its queued follow-up and unsent text through retry", async ({
creation,
startup,
page,
}, testInfo) => {
const prompt = "emit 1 coalesced agent stream updates for the retried agent.";
const followUp = "emit 1 coalesced agent stream updates for the retried follow-up.";
const draft = "Keep this unsent text through the failed agent start.";
await creation.openWorkspaceForm("local");
await creation.submitPrompt(prompt, "Create");
await creation.queueFollowUp(followUp);
await creation.typeComposerDraft(draft);
await startup.fail();
await test.step("the agent draft shows the prompt, its error and the queued follow-up", async () => {
await creation.expectFailedAgentDraft(prompt);
await creation.expectQueuedFollowUp(followUp);
await page.screenshot({ path: testInfo.outputPath("failed-agent-follow-up.png") });
});
await test.step("retrying delivers the prompt, then the follow-up, once", async () => {
await creation.resendFailedDraft();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectUserMessagesInOrder([prompt, followUp]);
await creation.expectComposerDraft(draft);
await page.screenshot({ path: testInfo.outputPath("retried-agent-follow-up.png") });
});
});
test("a forked worktree agent that fails to start keeps its setup and context through retry", async ({
creation,
lateStartup,
page,
}, testInfo) => {
const prompt = "Keep this fork's setup, image and history through the retry.";
await creation.openNestedAgentFork("worktree");
await creation.attachImage();
const startup = await lateStartup.arm();
await creation.submitPrompt(prompt, "Create");
await startup.fail();
await test.step("the agent draft keeps the fork's configuration and context", async () => {
await creation.expectFailedAgentDraft(prompt);
await expectComposerModel(page, "e2e-fast-stream");
await expectComposerMode(page, "Load test");
await creation.expectImageVisible();
await expectChatHistoryAttachment(page);
await page.screenshot({ path: testInfo.outputPath("failed-fork-draft.png") });
});
// An agent created in an existing workspace starts at the workspace root: the host ignores
// config.cwd when the request names a workspace, so the nested cwd is not asserted here.
await test.step("retrying creates one agent in the worktree with that configuration and context", async () => {
await creation.resendFailedDraft();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectAgentConfig({ model: "e2e-fast-stream", modeId: "load-test" });
await creation.expectImageVisible();
await expect(
page
.getByTestId("user-message")
.filter({ hasText: "Chat history" })
.filter({ visible: true }),
).toHaveCount(1);
await page.screenshot({ path: testInfo.outputPath("retried-fork-agent.png") });
});
});
test("a forked worktree whose workspace fails keeps its context through resubmission", async ({
creation,
provisioningFailure,
page,
}, testInfo) => {
const prompt = "Keep this fork's history when its workspace fails.";
await creation.openNestedAgentFork("worktree");
await provisioningFailure.arm();
await creation.submitPrompt(prompt, "Create");
await test.step("New workspace shows the prompt, its error and the fork's context", async () => {
await creation.expectReturnedToNewWorkspace(
prompt,
"Provisioning outcome is uncertain for test",
);
await expectChatHistoryAttachment(page);
await page.screenshot({ path: testInfo.outputPath("failed-fork-workspace.png") });
});
await test.step("resubmitting creates one agent with that context", async () => {
await creation.resubmitWorkspace();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await expect(
page
.getByTestId("user-message")
.filter({ hasText: "Chat history" })
.filter({ visible: true }),
).toHaveCount(1);
await page.screenshot({ path: testInfo.outputPath("resubmitted-fork-workspace.png") });
});
});
test("an unsupported host keeps the draft and asks for an update before creating anything", async ({
creation,
page,
}) => {
const host = await hideCreationLifecycle(page);
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Retain my unsupported host draft.", "Create");
await expect(
page
.getByText("Update this host to create a workspace with an agent.", { exact: true })
.first(),
).toBeVisible();
await expect(page).toHaveURL(/\/new\?/);
host.expectNoCreationRequest();
});
for (const isolation of ["local", "worktree"] as const) {
test(`a fork keeps its image and nested cwd in the optimistic and real ${isolation} agent`, async ({
creation,
page,
}, testInfo) => {
const provisioning = await holdWorkspaceProvisioning(page);
const cwd = await creation.openNestedAgentFork(isolation);
await creation.attachImage();
await creation.submitPrompt("Keep this image and nested cwd.", "Create");
const request = await provisioning.waitForRequest();
expect(request.agent!.config?.cwd).toBe(cwd);
expect(request.agent!.images).toHaveLength(1);
await creation.expectPromptVisible("Keep this image and nested cwd.");
await creation.expectImageVisible();
await page.screenshot({ path: testInfo.outputPath("pending-image-nested-cwd.png") });
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectNestedAgentCwd();
await creation.expectImageVisible();
await creation.expectInitialPromptOnce("Keep this image and nested cwd.");
});
}
test("closing the pending agent tab leaves host creation running", async ({ creation, page }) => {
const provisioning = await holdWorkspaceProvisioning(page);
await creation.openWorkspaceForm("local");
await creation.submitPrompt("Finish creation after I close the tab.", "Create");
const request = await provisioning.waitForRequest();
await creation.expectPromptVisible("Finish creation after I close the tab.");
await creation.closePendingAgent(request.agent!.agentId!);
provisioning.release();
await creation.expectOneCreatedWorkspace();
await creation.expectAgentCount(1);
await creation.expectInitialPromptOnce("Finish creation after I close the tab.");
});
test("an uncertain prompt receipt keeps the created agent tab and its timeline", async ({
creation,
page,
}) => {
await reportUnknownPromptOutcome(page);
await creation.openWorkspaceForm("local");
const prompt = "emit 1 coalesced agent stream updates for an uncertain first prompt.";
await creation.submitPrompt(prompt, "Create");
await creation.expectAssistantReply();
await creation.expectAgentNotWorking();
await creation.expectOneCreatedWorkspace();
await creation.expectInitialPromptOnce(prompt);
await creation.expectNoCreationRecovery();
await creation.expectAgentCount(1);
});
@@ -92,7 +92,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
});
}
test("current app requires a host update for chat workspaces and preserves existing-workspace agent creation", async ({
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
page,
project,
client,
@@ -104,34 +104,49 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, "Create");
await gate.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect(
page
.getByText("Update this host to create a workspace with an agent.", { exact: true })
.first(),
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
).toBeVisible();
await expect(page).toHaveURL(/\/new\?/);
expect((await client.fetchAgents()).entries).toHaveLength(0);
expect(
(await client.fetchWorkspaces()).entries.filter(
(workspace) => workspace.projectId === project.projectId,
),
).toHaveLength(1);
await page.goto(buildHostWorkspaceRoute(daemon.serverId, project.workspaceId));
const workspaces = (await client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
await expectPromptOnce(client, first.id, prompt);
await expect(
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
).toHaveText(prompt);
await createAgentTabFromMenu(page);
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await gate.waitForRequest();
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const agent = (await client.fetchAgents()).entries[0]!.agent;
expect(agent.workspaceId).toBe(project.workspaceId);
await client.waitForFinish(agent.id, 20_000);
await expectPromptOnce(client, agent.id, secondPrompt);
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
)!.agent;
expect(second.workspaceId).toBe(first.workspaceId);
await client.waitForFinish(second.id, 20_000);
await expectPromptOnce(client, second.id, secondPrompt);
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
).toHaveText(secondPrompt);
await expect(
page
.getByTestId("user-message")
.filter({ visible: true })
.filter({ hasText: secondPrompt }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
});
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
@@ -1,8 +1,7 @@
import { observeCreationRequests } from "../support/helpers/creation";
import {
composerLocator,
enterComposerFullscreen,
fillComposerDraft,
exitComposerFullscreen,
resizeComposerViewport,
} from "../support/helpers/composer";
import { expect, test } from "../support/fixtures";
@@ -96,7 +95,6 @@ test.describe("New workspace composer draft", () => {
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
const requests = await observeCreationRequests(page);
try {
await gotoAppShell(page);
@@ -136,9 +134,6 @@ test.describe("New workspace composer draft", () => {
}, DRAFT);
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
// Navigation now precedes creation; wait for success before checking consumed input.
await expect.poll(requests.workspaceRequests).toBe(1);
await requests.settled();
await openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceDraft(page, "");
} finally {
@@ -147,11 +142,10 @@ test.describe("New workspace composer draft", () => {
});
});
test("fullscreen creation opens the inline agent composer and keeps follow-up editing available", async ({
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
page,
}) => {
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
const followUpDraft = `${DRAFT}\nKeep this follow-up draft.`;
const delay = await delayBrowserWorkspaceCreatedResponse(page);
try {
await gotoAppShell(page);
@@ -163,23 +157,25 @@ test("fullscreen creation opens the inline agent composer and keeps follow-up ed
await page.getByRole("button", { name: "Create", exact: true }).click();
await delay.waitForCreateRequest();
await test.step("submission opens the normal agent panel inline", async () => {
await expect(page).toHaveURL(/\/workspace\//);
await test.step("submission returns inline with the original draft and spinner", async () => {
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(DRAFT);
await expect(composerLocator(page)).toHaveValue("");
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
});
await test.step("fullscreen can reopen to edit a follow-up while creation is pending", async () => {
await fillComposerDraft(page, followUpDraft);
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
await enterComposerFullscreen(page);
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
await expect(composerLocator(page)).toHaveValue(followUpDraft);
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
await exitComposerFullscreen(page);
});
delay.release();
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
await expect(composerLocator(page)).toHaveValue(followUpDraft);
await expect(composerLocator(page)).toHaveValue("");
await expect(composerLocator(page)).toBeEditable();
expect(delay.agentRequests).toHaveLength(1);
} finally {
@@ -192,16 +192,11 @@ test.describe("New Workspace dictation submit", () => {
await harness.waitForCreateRequest();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(page).toHaveURL(/\/workspace\//);
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(
TRANSCRIPT,
);
await expect(composer).toHaveValue("");
await expect(composer).toBeEditable();
await expect(composer).toHaveValue(TRANSCRIPT);
await expect(composer).not.toBeEditable();
harness.failWorkspaceCreation();
await expect(page).toHaveURL(/\/new\?/);
await expect(composer).toBeEditable();
await expect(composer).toHaveValue(TRANSCRIPT);
await expect(page.getByText(CREATE_FAILURE).first()).toBeVisible();
@@ -504,7 +504,7 @@ test.describe("New workspace flow", () => {
}
});
test("opens the final agent tab before agent creation resolves", async ({ page }) => {
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
@@ -568,10 +568,10 @@ test.describe("New workspace flow", () => {
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
await expect(draftTabs).toHaveCount(0);
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(1, { timeout: 30_000 });
).toHaveCount(0);
agentCreatedDelay.release();
await expect(
@@ -11,7 +11,10 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import {
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
page,
}, testInfo) => {
const serverId = getServerId();
const firstWorkspace = await seedWorkspace({
repoPrefix: "workspace-owner-",
title: "Original workspace",
});
const secondWorkspace = await seedWorkspace({
repoPrefix: "workspace-restored-",
title: "Restored workspace",
});
try {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: "Original workspace agent",
});
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: "Restored workspace agent",
});
await gotoAppShell(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await seedForeignPinnedAgentTab(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await testInfo.attach("hydrated-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
contentType: "image/png",
});
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await testInfo.attach("restored-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
contentType: "image/png",
});
await switchWorkspaceViaSidebar({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
const agents = await firstWorkspace.client.fetchAgents();
expect(
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
).toBe(firstWorkspace.workspaceId);
expect(
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
).toBe(secondWorkspace.workspaceId);
await testInfo.attach("original-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
contentType: "image/png",
});
} finally {
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
+27 -103
View File
@@ -11,24 +11,27 @@ import {
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
interface StartupGate {
release(): Promise<void>;
fail(): Promise<void>;
dispose(): Promise<void>;
}
/** Holds every later workspace agent's startup until it is released or failed once. */
async function installStartupGate(): Promise<StartupGate> {
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const gate = path.join(plugin.directory, "startup.txt");
const failure = path.join(plugin.directory, "startup-failure.txt");
const previous = await client.getDaemonConfig();
await writeFile(gate, "hold");
await writeFile(failure, "fail");
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
promptRejection: void;
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}>({
startup: async ({ page }, provide) => {
void page;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const gate = path.join(plugin.directory, "startup.txt");
const failure = path.join(plugin.directory, "startup-failure.txt");
const previous = await client.getDaemonConfig();
try {
await writeFile(gate, "hold");
await writeFile(failure, "fail");
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
import { readFile, rm } from "node:fs/promises";
export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => {
@@ -47,88 +50,17 @@ export default function contribute(server) {
return request;
});
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
return {
release: () => rm(gate, { force: true }),
// Replacing the command must not expose a truncated, empty file to the hook.
fail: () => rename(failure, gate),
async dispose() {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
await client.close();
},
};
}
export const test = base.extend<{
startup: StartupGate;
/** A startup gate installed mid-test, after agents that must start normally. */
lateStartup: { arm(): Promise<StartupGate> };
promptRejection: void;
/** Fails the next workspace request once it is armed. */
provisioningFailure: { arm(): Promise<void> };
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
workspacePromptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}>({
startup: async ({ page }, provide) => {
void page;
const gate = await installStartupGate();
try {
await provide(gate);
} finally {
await gate.dispose();
}
},
lateStartup: async ({ page }, provide) => {
void page;
const gates: StartupGate[] = [];
try {
await provide({
arm: async () => {
const gate = await installStartupGate();
gates.push(gate);
return gate;
},
});
} finally {
for (const gate of gates) await gate.dispose();
}
},
provisioningFailure: async ({ page, creation }, provide) => {
void page;
void creation;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const previous = await client.getDaemonConfig();
try {
const failure = path.join(plugin.directory, "provisioning-failure.txt");
// Fails one workspace request per arm, so a resubmission can succeed.
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
import { rm, stat } from "node:fs/promises";
export default function contribute(server) {
return server.before("workspace.create", async ({ request }) => {
const failure = ${JSON.stringify(failure)};
if (!(await stat(failure).catch(() => null))) return request;
await rm(failure, { force: true });
throw new Error("Provisioning outcome is uncertain for test");
});
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await provide({ arm: () => writeFile(failure, "fail") });
await provide({
release: () => rm(gate, { force: true }),
// Replacing the command must not expose a truncated, empty file to the hook.
fail: () => rename(failure, gate),
});
} finally {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
@@ -182,14 +114,6 @@ export default function contribute(server) {
gate.restore();
}
},
workspacePromptRetry: async ({ page }, provide) => {
const gate = await installDaemonWebSocketGate(page);
try {
await provide(createPromptRetryScenario(page, gate, "workspace"));
} finally {
gate.restore();
}
},
agentRetries: async ({ page }, provide) => {
await provide(await retryNextAgentCreation(page));
},
+13 -334
View File
@@ -1,17 +1,12 @@
import { awaitAssistantMessage } from "./agent-stream";
import { forkMostRecentAssistantTurnToNewWorkspace } from "./assistant-fork";
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import { gotoAppShell } from "./app";
import { gotoWorkspace } from "./launcher";
import { attachComposerTestImage, fillComposerDraft } from "./composer";
import { fillComposerDraft } from "./composer";
import { createAgentTabFromMenu } from "./workspace-tabs";
import {
connectNewWorkspaceDaemonClient,
openNewWorkspaceComposer,
openStartingRefPicker,
startingRefRow,
selectWorkspaceIsolation,
loadSessionMessageReaders,
} from "./new-workspace";
@@ -49,43 +44,6 @@ export async function captureWorkspaceAgentRequest(page: Page, options: { block:
return { waitForRequest: () => request };
}
/** Hold the creation request before the daemon can reserve or provision anything. */
/** Holds each workspace request with an agent until `release` sends the held requests. */
export async function holdWorkspaceProvisioning(page: Page) {
const frames = await loadSessionMessageReaders();
let held: Array<() => void> = [];
const requests: WorkspaceCreateRequest[] = [];
let agentPrompts = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
const message = frames.client(frame);
if (message?.type === "send_agent_message_request") agentPrompts++;
if (message?.type === "workspace.create.request" && message.agent) {
held.push(() => server.send(frame));
requests.push(message);
return;
}
server.send(frame);
});
server.onMessage((frame) => browser.send(frame));
});
async function waitForRequests(count: number) {
await expect.poll(() => requests.length).toBe(count);
return requests[count - 1]!;
}
return {
waitForRequest: () => waitForRequests(1),
waitForRequests,
release: () => {
const sending = held;
held = [];
for (const send of sending) send();
},
agentPrompts: () => agentPrompts,
};
}
export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<void> {
const create = page.getByRole("button", { name, exact: true });
await expect(create).toBeEnabled();
@@ -101,11 +59,9 @@ export async function pressSubmitBeforeTheNextRender(page: Page, name: string):
export async function observeCreationRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const pending = new Set<string>();
let workspaceRequests = 0;
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (request?.type === "workspace.create.request") workspaceRequests++;
if (
request?.type === "workspace.create.request" ||
request?.type === "agent.create.request"
@@ -128,7 +84,6 @@ export async function observeCreationRequests(page: Page) {
});
});
return {
workspaceRequests: () => workspaceRequests,
async settled() {
await expect.poll(() => pending.size).toBe(0);
},
@@ -183,101 +138,18 @@ export async function retryNextAgentCreation(page: Page) {
};
}
function agentComposer(page: Page) {
// Retained workspaces keep hidden composers mounted beside the focused one.
return page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true });
}
export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page);
const project = await seedWorkspace({
repoPrefix: "creation-idempotency-",
repo: {
files: [{ path: "nested/context.txt", content: "Nested agent working directory" }],
branches: ["recovery-base"],
},
});
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
let workspaceId = project.workspaceId;
const evictionWorkspaceIds = new Set<string>();
const sourceClients: Array<{ close(): Promise<void> }> = [];
return {
cleanup: async () => {
for (const client of sourceClients) await client.close();
await project.cleanup();
},
async openNestedAgentFork(isolation: "local" | "worktree") {
const cwd = `${project.repoPath}/nested`;
const sourceClient = await connectNewWorkspaceDaemonClient();
sourceClients.push(sourceClient);
const source = await sourceClient.createWorkspace({
source: { kind: "directory", path: project.repoPath },
agent: {
config: { provider: "mock", model: "e2e-fast-stream", modeId: "load-test", cwd },
initialPrompt: "emit 1 coalesced agent stream updates for the source conversation.",
},
});
if (!source.agent || !source.workspace)
throw new Error(source.error ?? "Missing nested source agent");
evictionWorkspaceIds.add(source.workspace.id);
await project.client.waitForFinish(source.agent.id, 20_000);
await gotoWorkspace(page, source.workspace.id);
await awaitAssistantMessage(page);
await forkMostRecentAssistantTurnToNewWorkspace(page);
await selectWorkspaceIsolation(page, isolation);
return cwd;
},
attachImage: () => attachComposerTestImage(page),
async expectImageVisible() {
await expect(
page
.getByRole("button", { name: "Open image attachment", exact: true })
.filter({ visible: true }),
).toHaveCount(1);
},
async expectInitialPromptOnce(prompt: string) {
const agents = (await project.client.fetchAgents()).entries.filter(
({ agent }) => agent.workspaceId === workspaceId,
);
expect(agents).toHaveLength(1);
const reader = await connectNewWorkspaceDaemonClient();
try {
const timeline = await reader.fetchAgentTimeline(agents[0]!.agent.id);
expect(timeline.entries.filter(({ item }) => item.type === "user_message")).toHaveLength(1);
expect(
timeline.entries.filter(
({ item }) => item.type === "user_message" && item.text === prompt,
),
).toHaveLength(1);
} finally {
await reader.close();
}
},
async expectNestedAgentCwd() {
const workspace = (await project.client.fetchWorkspaces()).entries.find(
(entry) => entry.id === workspaceId,
)!;
const nestedAgent = (await project.client.fetchAgents()).entries.find(
({ agent }) => agent.workspaceId === workspaceId,
)!.agent;
expect(nestedAgent.cwd).toBe(`${workspace.workspaceDirectory}/nested`);
},
async closePendingAgent(agentId: string) {
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).hover();
await page.getByTestId(`workspace-agent-close-${agentId}`).filter({ visible: true }).click();
await expect(
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
).toHaveCount(0);
},
cleanup: project.cleanup,
async openWorkspaceForm(isolation: "local" | "worktree") {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation);
},
async selectWorktreeBranch(name: string) {
await openStartingRefPicker(page);
await startingRefRow(page, `${name}, local branch`).click();
},
async openAgentDraft() {
await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page);
@@ -293,74 +165,6 @@ export async function createCreationScenario(page: Page) {
if (prompt) await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, button);
},
async expectNormalAgentComposer() {
await expect(agentComposer(page)).toBeEditable();
// Provisioning belongs to the workspace header and sidebar, not the conversation.
await expect(page.getByText("Creating workspace and agent…", { exact: true })).toHaveCount(0);
},
async expectAgentCannotBeStoppedYet() {
await expect(
page.getByRole("button", { name: "Stop agent" }).filter({ visible: true }),
).toBeDisabled();
},
async queueFollowUp(prompt: string) {
await agentComposer(page).fill(prompt);
await agentComposer(page).press("Enter");
await expect(
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
).toBeVisible();
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
},
async expectQueuedFollowUp(prompt: string) {
await expect(
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
).toHaveCount(1);
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
},
async resubmitWorkspace() {
await page.getByRole("button", { name: "Create", exact: true }).click();
},
async expectAgentConfig(config: { model: string; modeId: string }) {
const agent = (await project.client.fetchAgents()).entries.find(
(entry) => entry.agent.workspaceId === workspaceId,
)!.agent;
expect({ model: agent.model, modeId: agent.currentModeId }).toEqual(config);
},
async typeComposerDraft(text: string) {
await agentComposer(page).fill(text);
// The composer publishes typed text to its draft after the next paint.
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))),
);
},
async expectComposerDraft(text: string) {
await expect(agentComposer(page)).toHaveValue(text);
},
async expectAgentNotWorking() {
await expect(
page.getByTestId("turn-working-indicator").filter({ visible: true }),
).toHaveCount(0);
},
async expectTimelineSettled() {
await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
},
async expectUserMessagesInOrder(prompts: string[]) {
const agent = (await project.client.fetchAgents()).entries.find(
(entry) => entry.agent.workspaceId === workspaceId,
);
const reader = await connectNewWorkspaceDaemonClient();
try {
await expect
.poll(async () =>
(await reader.fetchAgentTimeline(agent!.agent.id)).entries.flatMap(({ item }) =>
item.type === "user_message" ? [item.text] : [],
),
)
.toEqual(prompts);
} finally {
await reader.close();
}
},
async expectPromptVisible(prompt?: string) {
// Inactive agent tabs retain their timeline DOM beside the visible draft.
const rows = page.getByTestId("user-message").filter({ visible: true });
@@ -371,7 +175,7 @@ export async function createCreationScenario(page: Page) {
await expect(page.getByTestId("user-message").first()).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
},
@@ -420,57 +224,15 @@ export async function createCreationScenario(page: Page) {
.first(),
).toBeVisible();
},
async expectFailedAgentDraft(prompt: string) {
await requests.settled();
await expect(
page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
await expect(
page
.getByText(/Creation startup failed for test/)
.filter({ visible: true })
.first(),
).toBeVisible();
await expect(agentComposer(page)).toHaveValue(prompt);
await this.expectNoCreationRecovery();
},
async resendFailedDraft() {
await page.getByRole("button", { name: "Send message", exact: true }).click();
},
async expectReturnedToNewWorkspace(prompt: string, error: string) {
await requests.settled();
await expect(page).toHaveURL(/\/new\?/);
await expect(agentComposer(page)).toHaveValue(prompt);
await expect(page.getByText(error).filter({ visible: true }).first()).toBeVisible();
await this.expectNoCreationRecovery();
expect(requests.workspaceRequests()).toBe(1);
expect(
(await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
),
).toHaveLength(0);
},
async expectNoCreationRecovery() {
await expect(page.getByRole("button", { name: "Retry creation", exact: true })).toHaveCount(
0,
);
await expect(page.getByRole("button", { name: "Return to draft", exact: true })).toHaveCount(
0,
);
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
},
async expectOneCreatedWorkspace() {
await expect(page).toHaveURL(/\/workspace\//);
await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) =>
workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
);
expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id;
@@ -503,11 +265,8 @@ export async function createCreationScenario(page: Page) {
.toBe(title);
},
async evictAndReturnToDraft() {
workspaceId = new URL(page.url()).pathname.split("/workspace/")[1]!;
const draft = page
.locator(
'[data-testid^="workspace-tab-draft_"][aria-selected="true"], [data-testid^="workspace-tab-agent_"][aria-selected="true"]',
)
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
.filter({ visible: true })
.first();
const draftTestId = await draft.getAttribute("data-testid");
@@ -517,7 +276,6 @@ export async function createCreationScenario(page: Page) {
source: { kind: "directory", path: project.repoPath },
});
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
evictionWorkspaceIds.add(result.workspace.id);
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
@@ -538,18 +296,15 @@ export async function createCreationScenario(page: Page) {
export function createPromptRetryScenario(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
kind: "agent" | "workspace" = "agent",
) {
const requestType = `${kind}.create.request`;
const responseType = `${kind}.create.response`;
let firstMessage: ReturnType<typeof gate.getClientRequests>[number] | undefined;
return {
holdAcknowledgement() {
gate.holdNextServerMessage(responseType);
gate.holdNextServerMessage("agent.create.response");
},
async waitForDeliveredPrompt() {
await gate.waitForHeldServerMessage(responseType);
firstMessage = gate.getClientRequests(requestType).at(-1);
await gate.waitForHeldServerMessage("agent.create.response");
firstMessage = gate.getClientRequests("agent.create.request").at(-1);
},
async disconnectAndReconnect() {
const fetches = gate.getClientRequestCount("fetch_agents_request");
@@ -560,89 +315,13 @@ export function createPromptRetryScenario(
.toBeGreaterThan(fetches);
},
async expectSameAgentAndMessage() {
expect(gate.getClientRequestCount(requestType)).toBe(1);
expect(gate.getClientRequestCount("agent.create.request")).toBe(1);
await expect
.poll(() => gate.getClientRequestCount("creation.subscribe.request"))
.toBeGreaterThan(0);
expect(gate.getClientRequests(requestType).at(-1)).toEqual(firstMessage);
expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0);
await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1);
},
};
}
export async function hideCreationLifecycle(page: Page) {
const frames = await loadSessionMessageReaders();
let requests = 0;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (frames.client(frame)?.type === "workspace.create.request") requests++;
server.send(frame);
});
server.onMessage((frame) => {
const message = frames.server(frame);
if (
message?.type === "status" &&
message.payload.status === "server_info" &&
typeof message.payload.features === "object" &&
message.payload.features !== null
) {
browser.send(
JSON.stringify({
type: "session",
message: {
...message,
payload: {
...message.payload,
features: { ...message.payload.features, creationLifecycle: false },
},
},
}),
);
return;
}
browser.send(frame);
});
});
return { expectNoCreationRequest: () => expect(requests).toBe(0) };
}
/** Simulate an uncertain receipt after the real daemon registered and ran the initial agent. */
export async function reportUnknownPromptOutcome(page: Page) {
const frames = await loadSessionMessageReaders();
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => server.send(frame));
server.onMessage((frame) => {
const message = frames.server(frame);
if (
message?.type === "workspace.create.response" &&
message.payload.agent &&
message.payload.creation
) {
const error = "Initial prompt outcome is uncertain for test";
browser.send(
JSON.stringify({
...JSON.parse(frame.toString()),
message: {
...message,
payload: {
...message.payload,
error,
creation: {
...message.payload.creation,
phase: "failed",
error,
outcomeUnknown: true,
},
},
},
}),
);
return;
}
browser.send(frame);
});
});
}
@@ -18,7 +18,6 @@ type NewWorkspaceDaemonClient = Pick<
| "createPaseoWorktree"
| "createWorkspace"
| "fetchAgents"
| "fetchAgentTimeline"
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
@@ -1,12 +1,47 @@
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
} from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id";
export async function seedForeignPinnedAgentTab(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
.parse(JSON.parse(raw));
const key = buildWorkspaceTabPersistenceKey(input);
const layout = key ? stored.state.layoutByWorkspace[key] : null;
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
const opened = openTabInLayoutFocused({
layout,
target: { kind: "agent", agentId: input.agentId },
now: 1,
placement: { mode: "focused" },
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
});
if (!opened) throw new Error("Failed to seed foreign agent tab");
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
pinnedAgentIdsByWorkspace: {
...stored.state.pinnedAgentIdsByWorkspace,
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.1",
"version": "0.13.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -61,7 +61,6 @@
"@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1",
"@noble/hashes": "^2.4.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native/normalize-colors": "0.86.3",
+2 -6
View File
@@ -1,4 +1,3 @@
import { useAgentCreation } from "@/workspace-creation";
import { useShallow } from "zustand/shallow";
import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry";
@@ -34,7 +33,6 @@ export function useAgentDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const creation = useAgentCreation(context.serverId, target.agentId);
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
@@ -51,10 +49,8 @@ export function useAgentDescriptor(
};
}),
);
const provider = creation ? creation.provider : descriptorState.provider;
const title = creation ? creation.text : descriptorState.title;
const icon = useProviderIcon(provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, provider, title, icon });
const icon = useProviderIcon(descriptorState.provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, icon });
}
export function buildAgentDescriptor(input: {
@@ -30,7 +30,7 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityIconBox } from "@/styles/density";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -463,12 +463,12 @@ const openInNewWindowLeadingIcon = (
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
);
const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
<ThemedMoreVertical uniProps={hovered ? kebabTriggerIconHoveredProps : kebabTriggerIconProps} />
);
}
@@ -2705,8 +2705,12 @@ const styles = StyleSheet.create((theme) => ({
marginRight: -6,
},
projectKebabButton: {
width: 24,
height: 24,
// Larger on compact so the kebab is an easy tap target. The negative margins keep its
// dots on the same rail as the 24px desktop box and the row at its 44px height.
width: { xs: 32, md: 24 },
height: { xs: 32, md: 24 },
marginRight: { xs: -4, md: 0 },
marginVertical: { xs: -4, md: 0 },
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
@@ -17,6 +17,7 @@ import { isWeb } from "@/constants/platform";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import { densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import {
@@ -70,13 +71,11 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
return <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
}
export interface SidebarWorkspaceMenuProps {
@@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
const styles = StyleSheet.create((theme) => ({
trigger: {
padding: 2,
// Larger on compact so the kebab is an easy tap target.
width: { xs: 32, md: 18 },
height: { xs: 32, md: 18 },
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
marginLeft: 2,
// Center the compact box on the 20px title line it is anchored to.
marginTop: { xs: -6, md: 0 },
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
// pull the painted dots through that unused view-box space onto the trailing-content rail.
marginRight: -7,
marginRight: { xs: -14, md: -7 },
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
@@ -1,5 +1,3 @@
import { useWorkspaceCreation } from "@/workspace-creation";
import { CreatingWorkspaceRow } from "@/workspace-creation/ui";
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text, type GestureResponderEvent } from "react-native";
@@ -60,16 +58,7 @@ interface SidebarWorkspaceRowProps {
dragHandleProps?: DraggableListDragHandleProps;
}
export function SidebarWorkspaceRow(props: SidebarWorkspaceRowProps) {
const creation = useWorkspaceCreation(props.workspace.serverId, props.workspace.workspaceId);
if (creation && !creation.workspaceObserved)
return (
<CreatingWorkspaceRow attempt={creation} onPress={props.onPress} selected={props.selected} />
);
return <ReadySidebarWorkspaceRow {...props} />;
}
function ReadySidebarWorkspaceRow({
export function SidebarWorkspaceRow({
workspace,
selected,
shortcutNumber,
-82
View File
@@ -296,7 +296,6 @@ describe("cancelComposerAgent", () => {
function baseInput(): {
client: ComposerCancelClient & { canceledIds: string[] };
agentId: string;
isAgentPublished: boolean;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
@@ -310,7 +309,6 @@ describe("cancelComposerAgent", () => {
},
},
agentId: "agent",
isAgentPublished: true,
isAgentRunning: true,
isCancellingAgent: false,
isConnected: true,
@@ -342,13 +340,6 @@ describe("cancelComposerAgent", () => {
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing while the host has not published the agent", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isAgentPublished: false });
expect(result).toBeNull();
expect(input.client.canceledIds).toEqual([]);
});
it("does nothing when the agent is already being canceled", () => {
const input = baseInput();
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
@@ -428,29 +419,6 @@ describe("pickAndPersistImages", () => {
});
describe("dispatchComposerAgentMessage", () => {
it("queues a prompt behind earlier ones without sending while the agent is not ready", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
const queued: QueuedComposerMessage = { id: "later", text: "later", attachments: [] };
const queue = createFakeQueue(new Map([["agent", [queued]]]));
const outcome = await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "next follow-up",
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: false,
queue,
});
expect(outcome).toBe("queued");
expect(client.calls).toEqual([]);
expect(stream.tail.get("agent") ?? []).toEqual([]);
expect(queue.read("agent").map((item) => item.text)).toEqual(["later", "next follow-up"]);
});
it("forwards the configured active-turn intent without provider capability checks", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
@@ -462,8 +430,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: async () => [],
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
});
@@ -480,8 +446,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: async () => [],
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
activeTurnId: "turn-1",
});
@@ -494,8 +458,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: async () => [],
submission: legacy,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
});
expect(legacy.tail.get("legacy")?.[0]?.turnId).toBeUndefined();
@@ -514,8 +476,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}),
).rejects.toBe(rejection);
@@ -536,8 +496,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}),
).rejects.toBe(transportError);
@@ -561,8 +519,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission,
isAgentReady: true,
queue: createFakeQueue(),
}),
).rejects.toBe(transportError);
});
@@ -584,8 +540,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer",
}),
).rejects.toBe(transportError);
@@ -606,8 +560,6 @@ describe("dispatchComposerAgentMessage", () => {
],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls).toHaveLength(1);
@@ -654,8 +606,6 @@ describe("dispatchComposerAgentMessage", () => {
attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0].options.attachments).toEqual([
@@ -689,8 +639,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(stream.head.get("agent")).toHaveLength(2);
@@ -708,8 +656,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0]?.options).toMatchObject({
@@ -730,8 +676,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [review],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
@@ -750,8 +694,6 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [browserElement],
encodeImages: passthroughEncodeImages,
submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
});
expect(client.calls[0]?.options.attachments).toEqual([
@@ -854,7 +796,6 @@ describe("sendQueuedComposerMessageNow", () => {
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: true,
messageId: "msg-1",
queue,
submitMessage: async (input) => {
@@ -873,7 +814,6 @@ describe("sendQueuedComposerMessageNow", () => {
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: true,
messageId: "msg-1",
queue,
submitMessage: async (input) => {
@@ -885,27 +825,6 @@ describe("sendQueuedComposerMessageNow", () => {
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
});
it("leaves the queue untouched while the agent is not ready", async () => {
const queued: QueuedComposerMessage[] = [
{ id: "msg-1", text: "first", attachments: [] },
{ id: "msg-2", text: "second", attachments: [] },
];
const queue = createFakeQueue(new Map([["agent", queued]]));
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: false,
messageId: "msg-2",
queue,
submitMessage: async (input) => {
submitted.push(input);
},
});
expect(result).toEqual({ status: "waiting" });
expect(submitted).toEqual([]);
expect(queue.state.get("agent")).toBe(queued);
});
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
const queue = createFakeQueue(
new Map([
@@ -920,7 +839,6 @@ describe("sendQueuedComposerMessageNow", () => {
);
const result = await sendQueuedComposerMessageNow({
agentId: "agent",
isAgentReady: true,
messageId: "msg-1",
queue,
submitMessage: async () => {
+2 -16
View File
@@ -170,14 +170,13 @@ export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(in
export interface CancelComposerAgentInput {
client: ComposerCancelClient | null;
agentId: string;
isAgentPublished: boolean;
isAgentRunning: boolean;
isCancellingAgent: boolean;
isConnected: boolean;
}
export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<void> | null {
if (!input.isAgentPublished || !input.isAgentRunning || input.isCancellingAgent) return null;
if (!input.isAgentRunning || input.isCancellingAgent) return null;
if (!input.isConnected || !input.client) return null;
try {
return Promise.resolve(input.client.cancelAgent(input.agentId));
@@ -189,9 +188,6 @@ export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<vo
export interface DispatchComposerAgentMessageInput {
client: ComposerSendClient;
agentId: string;
/** See isAgentReadyForPrompts. Prompts for an agent that is not ready wait in its queue. */
isAgentReady: boolean;
queue: QueueWriter;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
@@ -205,12 +201,7 @@ export interface DispatchComposerAgentMessageInput {
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<"sent" | "queued"> {
if (!input.isAgentReady) {
// A creating agent takes prompts after its initial one; this waits behind it in the queue.
queueComposerMessage(input);
return "queued";
}
): Promise<void> {
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
@@ -235,7 +226,6 @@ export async function dispatchComposerAgentMessage(
attachments: wirePayload.attachments,
});
input.submission.accept(input.agentId, clientMessageId);
return "sent";
} catch (error) {
input.submission.reject(input.agentId, clientMessageId);
throw error;
@@ -303,8 +293,6 @@ export function editQueuedComposerMessage(
export interface SendQueuedComposerMessageNowInput {
agentId: string;
/** See DispatchComposerAgentMessageInput. A queued prompt stays in place until then. */
isAgentReady: boolean;
messageId: string;
queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
@@ -313,7 +301,6 @@ export interface SendQueuedComposerMessageNowInput {
export type SendQueuedComposerMessageNowResult =
| { status: "missing" }
| { status: "waiting" }
| { status: "submitted" }
| { status: "failed"; errorMessage: string };
@@ -322,7 +309,6 @@ export async function sendQueuedComposerMessageNow(
): Promise<SendQueuedComposerMessageNowResult> {
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
if (!item) return { status: "missing" };
if (!input.isAgentReady) return { status: "waiting" };
input.queue.write((prev) => {
const next = new Map(prev);
next.set(
@@ -35,7 +35,6 @@ import {
} from "@/provider-selection/provider-selection";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { useSessionStore } from "@/stores/session-store";
import { useAgentCreation } from "@/workspace-creation";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
@@ -421,56 +420,6 @@ function selectAgentControlsSlice(
};
}
/** A creating agent shows its submitted configuration, read-only, until the host publishes it. */
function useAgentControlsSource(serverId: string, agentId: string) {
const publishedAgent = useSessionStore(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
);
const creation = useAgentCreation(serverId, agentId);
const hasClient = useSessionStore((state) => Boolean(state.sessions[serverId]?.client));
const agent = useMemo<AgentControlsSlice>(() => {
if (publishedAgent || !creation) return publishedAgent;
return {
provider: creation.provider,
// The destination is not known yet; never stand in the source directory for it.
cwd: null,
runtimeModelId: null,
model: creation.config.model,
features: undefined,
runtimeThinkingOptionId: null,
thinkingOptionId: creation.config.thinkingOptionId,
lastUsage: undefined,
};
}, [creation, publishedAgent]);
return {
agent,
isPublished: publishedAgent !== null,
canEdit: hasClient && publishedAgent !== null,
submittedModeId: creation?.config.modeId ?? null,
};
}
function ignoreModeSelection(): void {}
function resolveModeControl(input: {
live: AgentModeControlValue | null;
isPublished: boolean;
submittedModeId: string | null;
entry: ReturnType<typeof resolveSnapshotSelectedEntry>;
providerDefinitions: AgentProviderDefinition[];
}): AgentModeControlValue | null {
if (input.isPublished) return input.live;
if (input.entry?.status !== "ready" || !input.entry.modes?.length) return null;
return {
provider: input.entry.provider,
providerDefinitions: input.providerDefinitions,
modeOptions: input.entry.modes,
selectedModeId: input.submittedModeId,
onSelectMode: ignoreModeSelection,
disabled: true,
};
}
function resolveSnapshotSelectedEntry(
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
agentProvider: string | undefined,
@@ -1607,13 +1556,14 @@ export const AgentControls = memo(function AgentControls({
isCompactLayout,
}: AgentControlsProps) {
const { updatePreferences } = useFormPreferences();
const { agent, isPublished, canEdit, submittedModeId } = useAgentControlsSource(
serverId,
agentId,
const agent = useSessionStore(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const toast = useToast();
const liveModeControl = useLiveAgentModeControl(serverId, agentId);
const modeControl = useLiveAgentModeControl(serverId, agentId);
const commandCenterModes = toCommandCenterModes(modeControl);
const modeProviderDefinitions = getModeProviderDefinitions(modeControl);
const {
entries: snapshotEntries,
@@ -1636,26 +1586,6 @@ export const AgentControls = memo(function AgentControls({
[agent?.provider, snapshotEntries],
);
const modeControl = useMemo(
() =>
resolveModeControl({
live: liveModeControl,
isPublished,
submittedModeId,
entry: snapshotSelectedEntry,
providerDefinitions: agentProviderDefinitions,
}),
[
agentProviderDefinitions,
isPublished,
liveModeControl,
snapshotSelectedEntry,
submittedModeId,
],
);
const commandCenterModes = toCommandCenterModes(modeControl);
const modeProviderDefinitions = getModeProviderDefinitions(modeControl);
const agentProviderModels = useMemo(
() => buildAgentProviderModels(agent?.provider, models),
[agent?.provider, models],
@@ -1839,7 +1769,7 @@ export const AgentControls = memo(function AgentControls({
const commandCenterRegistration = (
<AgentControlCommandCenterRegistration
sourceId={`agent:${serverId}:${agentId}`}
enabled={canEdit}
enabled={Boolean(client)}
controls={commandCenterControls}
/>
);
@@ -1884,7 +1814,7 @@ export const AgentControls = memo(function AgentControls({
onRetryModelProvider={handleRetryModelProvider}
isRetryingModelProvider={snapshotIsRefreshing}
onDropdownClose={onDropdownClose}
disabled={!canEdit}
disabled={!client}
modeControl={modeControl}
modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
@@ -63,7 +63,6 @@ export interface AgentInputDraft {
attachments: UserComposerAttachment[];
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
flush: () => void;
isHydrated: boolean;
attachmentFocusRequestId: number;
composerState: DraftComposerState | null;
@@ -173,8 +172,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[draftKey, textPublication],
);
const flushDraft = useCallback(() => textPublication.flush(), [textPublication]);
const replaceText = useCallback(
(nextText: string) => {
textPublication.cancel();
@@ -354,7 +351,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
return {
textSource,
editText,
flush: flushDraft,
replaceText,
textReplacement,
attachments,
@@ -49,7 +49,6 @@ import {
} from "@/workspace-tabs/model";
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings";
import { transferComposerFollowUps } from "@/composer/submission/writer";
const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -185,7 +184,8 @@ async function submitDraftCreateRequest(input: {
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
};
const result = await client.createAgent(options);
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
const result = creation ? await creation.retry(options) : await client.createAgent(options);
return {
agentId: result.id,
@@ -466,6 +466,10 @@ export function WorkspaceDraftAgentTab({
selectModelMessage: t("workspaceSetup.errors.selectModel"),
}),
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
if (pendingAutoSubmit?.agentCreation) {
const result = await pendingAutoSubmit.agentCreation.result;
return { agentId: result.id, result };
}
return submitDraftCreateRequest({
draftId,
attempt,
@@ -487,7 +491,6 @@ export function WorkspaceDraftAgentTab({
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
onCreated(result);
transferComposerFollowUps(serverId, tabId, result.id);
},
});
const turnPresentation = useMemo(
+25 -31
View File
@@ -58,7 +58,6 @@ import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import {
selectAgentSentMessages,
selectAgentTurnPresentation,
selectIsAgentPublished,
useSessionStore,
} from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
@@ -89,9 +88,9 @@ import {
toggleForgeAttachmentFromPicker,
uploadFileAttachments,
type AttachmentPersister,
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
import { isAgentReadyForPrompts } from "@/workspace-creation";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -124,10 +123,7 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall";
import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler";
@@ -262,8 +258,8 @@ function resolveCheckoutRemoteUrl(
return checkoutStatus?.remoteUrl ?? null;
}
function buildCancelButtonStyle(canCancel: boolean, isCancellingAgent: boolean): object[] {
const disabled = !canCancel || isCancellingAgent ? styles.buttonDisabled : undefined;
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
}
@@ -1087,7 +1083,7 @@ interface ComposerCancelButtonProps {
buttonIconSize: number;
cancelButtonStyle: (object | undefined)[];
handleCancelAgent: () => void;
canCancel: boolean;
isConnected: boolean;
isCancellingAgent: boolean;
agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
@@ -1097,7 +1093,7 @@ function ComposerCancelButton({
buttonIconSize,
cancelButtonStyle,
handleCancelAgent,
canCancel,
isConnected,
isCancellingAgent,
agentInterruptKeys,
t,
@@ -1115,7 +1111,7 @@ function ComposerCancelButton({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
disabled={!canCancel || isCancellingAgent}
disabled={!isConnected || isCancellingAgent}
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
style={cancelButtonStyle}
@@ -1310,6 +1306,8 @@ function ComposerContentImpl({
);
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
@@ -1572,12 +1570,9 @@ function ComposerContentImpl({
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const session = useSessionStore.getState().sessions[serverId];
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
isAgentReady: isAgentReadyForPrompts(serverId, targetAgentId),
queue: createAgentMessageQueueWriter(serverId),
text,
attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
@@ -1588,7 +1583,10 @@ function ComposerContentImpl({
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(session, targetAgentId).turnId ?? undefined)
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
targetAgentId,
).turnId ?? undefined)
: undefined,
});
onAttentionPromptSend?.();
@@ -1622,7 +1620,13 @@ function ComposerContentImpl({
);
const hasAgent = agentState.status !== null;
const queueWriter = useMemo(() => createAgentMessageQueueWriter(serverId), [serverId]);
const queueWriter = useMemo<QueueWriter>(
() => ({
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
write: (updater) => setQueuedMessages(serverId, updater),
}),
[serverId, setQueuedMessages],
);
const queueMessage = useCallback(
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
@@ -1892,10 +1896,6 @@ function ComposerContentImpl({
const cancellation = cancelComposerAgent({
client,
agentId: targetAgentId,
isAgentPublished: selectIsAgentPublished(
useSessionStore.getState().sessions[serverId],
targetAgentId,
),
isAgentRunning,
isCancellingAgent,
isConnected,
@@ -1952,7 +1952,6 @@ function ComposerContentImpl({
// Reuse the regular send path; server-side send atomically interrupts any active run.
const result = await sendQueuedComposerMessageNow({
agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
messageId: id,
queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) =>
@@ -1963,7 +1962,7 @@ function ComposerContentImpl({
setSendError(result.errorMessage);
}
},
[agentId, queueWriter, serverId, submitMessage, t],
[agentId, queueWriter, submitMessage, t],
);
const handleQueue = useCallback(
@@ -2011,14 +2010,9 @@ function ComposerContentImpl({
[agentId, recall, replaceUserInput, serverId],
);
const isAgentPublished = useSessionStore((state) =>
selectIsAgentPublished(state.sessions[serverId], agentId),
);
// A creating agent cannot be stopped until the host publishes it.
const canCancelAgent = isConnected && isAgentPublished;
const cancelButtonStyle = useMemo(
() => buildCancelButtonStyle(canCancelAgent, isCancellingAgent),
[canCancelAgent, isCancellingAgent],
() => buildCancelButtonStyle(isConnected, isCancellingAgent),
[isConnected, isCancellingAgent],
);
const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
@@ -2035,7 +2029,7 @@ function ComposerContentImpl({
buttonIconSize={buttonIconSize}
cancelButtonStyle={cancelButtonStyle}
handleCancelAgent={handleCancelAgent}
canCancel={canCancelAgent}
isConnected={isConnected}
isCancellingAgent={isCancellingAgent}
agentInterruptKeys={agentInterruptKeys}
t={t}
@@ -2045,9 +2039,9 @@ function ComposerContentImpl({
agentInterruptKeys,
buttonIconSize,
cancelButtonStyle,
canCancelAgent,
handleCancelAgent,
isCancellingAgent,
isConnected,
t,
],
);
@@ -2,9 +2,7 @@ import { afterEach, expect, test } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import type { UserMessageItem } from "@/types/stream";
import { useDraftStore } from "@/stores/draft-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { createMessageSubmissionWriter, transferComposerFollowUps } from "./writer";
import { createMessageSubmissionWriter } from "./writer";
const CANONICAL_ONLY_SERVER_ID = "canonical-only";
@@ -49,59 +47,3 @@ test("tracks submissions when canonical prompts are supported", () => {
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
});
const TRANSFER_SERVER_ID = "transfer";
function queued(id: string) {
return { id, text: `follow-up ${id}`, attachments: [] };
}
function queueIds(agentId: string): string[] {
return (
useSessionStore.getState().sessions[TRANSFER_SERVER_ID]?.queuedMessages.get(agentId) ?? []
).map((item) => item.id);
}
function draftText(agentId: string): string | undefined {
return useDraftStore
.getState()
.getDraftInput(buildDraftStoreKey({ serverId: TRANSFER_SERVER_ID, agentId }))?.text;
}
function typeDraft(agentId: string, text: string): void {
useDraftStore.getState().saveDraftInput({
draftKey: buildDraftStoreKey({ serverId: TRANSFER_SERVER_ID, agentId }),
draft: { text, attachments: [] },
});
}
test("follow-ups move with their IDs and order behind the destination's, and only once", () => {
initializeSession(TRANSFER_SERVER_ID, {});
useSessionStore.getState().setQueuedMessages(
TRANSFER_SERVER_ID,
new Map([
["optimistic", [queued("a"), queued("b")]],
["recovery", [queued("earlier")]],
]),
);
typeDraft("optimistic", "typed but unsent");
transferComposerFollowUps(TRANSFER_SERVER_ID, "optimistic", "recovery");
transferComposerFollowUps(TRANSFER_SERVER_ID, "optimistic", "recovery");
transferComposerFollowUps(TRANSFER_SERVER_ID, "recovery", "recovery");
expect(queueIds("recovery")).toEqual(["earlier", "a", "b"]);
expect(queueIds("optimistic")).toEqual([]);
expect(draftText("recovery")).toBe("typed but unsent");
expect(draftText("optimistic")).toBeUndefined();
useSessionStore.getState().clearSession(TRANSFER_SERVER_ID);
});
test("unsent text never replaces a destination draft and stays at its source", () => {
initializeSession(TRANSFER_SERVER_ID, {});
typeDraft("source", "typed on the source");
typeDraft("destination", "already typed here");
transferComposerFollowUps(TRANSFER_SERVER_ID, "source", "destination");
expect(draftText("destination")).toBe("already typed here");
expect(draftText("source")).toBe("typed on the source");
useSessionStore.getState().clearSession(TRANSFER_SERVER_ID);
});
+1 -49
View File
@@ -1,7 +1,5 @@
import type { MessageSubmissionWriter, QueueWriter } from "@/composer/actions";
import type { MessageSubmissionWriter } from "@/composer/actions";
import { useSessionStore } from "@/stores/session-store";
import { useDraftStore, type DraftInput } from "@/stores/draft-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import {
appendSubmittedUserMessage,
removeSubmittedUserMessage,
@@ -65,14 +63,6 @@ export function createMessageSubmissionWriter(serverId: string): MessageSubmissi
};
}
export function createAgentMessageQueueWriter(serverId: string): QueueWriter {
return {
read: (agentId) =>
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(agentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
};
}
export function handoffCreatedAgentMessageSubmission(
serverId: string,
agentId: string,
@@ -80,41 +70,3 @@ export function handoffCreatedAgentMessageSubmission(
): boolean {
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
}
function isEmptyDraft(draft: DraftInput | undefined): boolean {
return !draft || (!draft.text.trim() && draft.attachments.length === 0);
}
/**
* Moves the queued follow-ups and the unsent agent draft of one composer identity to the identity
* that now owns them, such as a recovery draft or the agent it creates. Queued messages keep their
* IDs and order, after any already queued there; an idle destination agent drains them. The unsent
* draft moves only into an empty slot, and otherwise stays where it is. Repeating is harmless.
*/
export function transferComposerFollowUps(serverId: string, from: string, to: string): void {
if (from === to) return;
const session = useSessionStore.getState().sessions[serverId];
if (session?.queuedMessages.get(from)?.length) {
useSessionStore.getState().setQueuedMessages(serverId, (queues) => {
const moving = queues.get(from);
if (!moving?.length) return queues;
const next = new Map(queues);
next.set(to, [...(queues.get(to) ?? []), ...moving]);
next.delete(from);
return next;
});
const agent = session.agents.get(to) ?? session.agentDetails.get(to);
if (agent?.turn.phase === "idle") {
void import("@/runtime/host-runtime").then(({ getHostRuntimeStore }) =>
getHostRuntimeStore().drainQueuedAgentMessage(serverId, to),
);
}
}
const drafts = useDraftStore.getState();
const source = buildDraftStoreKey({ serverId, agentId: from });
const destination = buildDraftStoreKey({ serverId, agentId: to });
const draft = drafts.getDraftInput(source);
if (!draft || isEmptyDraft(draft) || !isEmptyDraft(drafts.getDraftInput(destination))) return;
drafts.saveDraftInput({ draftKey: destination, draft });
drafts.clearDraftInput({ draftKey: source });
}
@@ -1,4 +1,3 @@
import type { CreationAttempt } from "@/workspace-creation/model";
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
import equal from "fast-deep-equal";
import type { PrHint } from "@/git/pr-hint";
@@ -147,7 +146,6 @@ export function createSidebarWorkspaceEntry(input: {
workspace: WorkspaceDescriptor;
projectViewKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry {
const projectViewKey = input.projectViewKey ?? input.workspace.projectId;
@@ -205,7 +203,6 @@ export function deriveProjectStatusBucket(input: {
workspaces: readonly SidebarWorkspacePlacement[];
sessions: Record<string, ProjectStatusSession | undefined>;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
}): SidebarStateBucket {
const workspaceIdsByServer = new Map<string, string[]>();
for (const placement of input.workspaces) {
@@ -233,7 +230,6 @@ export function deriveProjectStatusBucket(input: {
serverId,
workspace,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceCreations: input.workspaceCreations,
workspaceAgentActivity: session.workspaceAgentActivity,
}).status,
);
@@ -352,7 +348,6 @@ export function buildSidebarWorkspaceEntries(input: {
placements: readonly SidebarWorkspacePlacement[];
sessions: SidebarWorkspaceSession[];
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
}): ReadonlyMap<string, SidebarWorkspaceEntry> {
if (input.placements.length === 0 || input.sessions.length === 0) {
@@ -379,7 +374,6 @@ export function buildSidebarWorkspaceEntries(input: {
workspace,
projectViewKey: placement.projectViewKey,
pendingCreateAttempts: input.pendingCreateAttempts,
workspaceCreations: input.workspaceCreations,
workspaceAgentActivity: session.workspaceAgentActivity,
});
const previousEntry = input.previousEntries?.get(placement.workspaceKey);
@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { useWorkspaceCreations } from "@/workspace-creation";
import { creatingSidebarEntry } from "@/workspace-creation/presentation";
import { useMemo, useRef } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -22,7 +19,6 @@ export function useSidebarWorkspaceEntries(
placements: readonly SidebarWorkspacePlacement[],
enabled = true,
): ReadonlyMap<string, SidebarWorkspaceEntry> {
const { t } = useTranslation();
const serverIds = useMemo(
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
[placements],
@@ -36,7 +32,6 @@ export function useSidebarWorkspaceEntries(
const pendingCreateAttempts = useCreateFlowStore((state) =>
enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
);
const creations = useWorkspaceCreations();
const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES);
// Collection ownership is intentional: retained sidebars have one cheap
@@ -50,26 +45,13 @@ export function useSidebarWorkspaceEntries(
previousEntriesRef.current = EMPTY_ENTRIES;
return EMPTY_ENTRIES;
}
const entries = new Map(
buildSidebarWorkspaceEntries({
placements,
sessions,
pendingCreateAttempts,
workspaceCreations: creations,
previousEntries: previousEntriesRef.current,
}),
);
for (const placement of placements) {
if (entries.has(placement.workspaceKey)) continue;
const creating = creations.some(
(creation) =>
creation.serverId === placement.serverId &&
creation.workspaceId === placement.workspaceId &&
!creation.workspaceObserved,
);
if (creating) entries.set(placement.workspaceKey, creatingSidebarEntry(placement, t));
}
const entries = buildSidebarWorkspaceEntries({
placements,
sessions,
pendingCreateAttempts,
previousEntries: previousEntriesRef.current,
});
previousEntriesRef.current = entries;
return entries;
}, [enabled, pendingCreateAttempts, placements, sessions, creations, t]);
}, [enabled, pendingCreateAttempts, placements, sessions]);
}
@@ -1,6 +1,3 @@
import { useTranslation } from "react-i18next";
import { useWorkspaceCreations } from "@/workspace-creation";
import { addCreatingSidebarWorkspaces } from "@/workspace-creation/presentation";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useCallback, useEffect, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -62,7 +59,6 @@ export function useSidebarProjectStatusBucket(input: {
enabled: boolean;
}): SidebarStateBucket | null {
const { workspaces, enabled } = input;
const workspaceCreations = useWorkspaceCreations();
const pendingCreateAttempts = useStoreWithEqualityFn(
useCreateFlowStore,
(state) => state.pendingByDraftId,
@@ -76,10 +72,9 @@ export function useSidebarProjectStatusBucket(input: {
workspaces,
sessions: state.sessions,
pendingCreateAttempts,
workspaceCreations,
});
},
[enabled, pendingCreateAttempts, workspaces, workspaceCreations],
[enabled, pendingCreateAttempts, workspaces],
);
return useStoreWithEqualityFn(useSessionStore, selector, Object.is);
@@ -155,12 +150,10 @@ export function useSidebarWorkspacesList(options?: {
: undefined;
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
const { t } = useTranslation();
const creations = useWorkspaceCreations();
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
const sidebarModel = useMemo(
() => addCreatingSidebarWorkspaces(selectPlacements(hostProjects), creations, t),
[hostProjects, selectPlacements, creations, t],
() => selectPlacements(hostProjects),
[hostProjects, selectPlacements],
);
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
-9
View File
@@ -1339,15 +1339,6 @@ export const ar: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "جارٍ إنشاء مساحة العمل…",
creatingLabel: "جارٍ إنشاء مساحة العمل",
updateHost: "حدّث هذا المضيف لإنشاء مساحة عمل تتضمن وكيلاً.",
chooseProjectHost: "اختر مشروعًا ومضيفًا",
projectUnavailable: "المشروع غير متاح على المضيف المحدد",
workspacePending: "لا يزال إنشاء مساحة العمل جاريًا.",
agentPending: "لا يزال إنشاء الوكيل جاريًا.",
},
title: "مساحة عمل جديدة",
create: "يخلق",
isolation: {
-9
View File
@@ -1347,15 +1347,6 @@ export const en = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Creating workspace…",
creatingLabel: "Creating workspace",
updateHost: "Update this host to create a workspace with an agent.",
chooseProjectHost: "Choose a project and host",
projectUnavailable: "Project is not available on the selected host",
workspacePending: "Workspace is still being created.",
agentPending: "Agent is still being created.",
},
title: "New workspace",
create: "Create",
isolation: {
-9
View File
@@ -1375,15 +1375,6 @@ export const es: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Creando espacio de trabajo…",
creatingLabel: "Creando espacio de trabajo",
updateHost: "Actualiza este host para crear un espacio de trabajo con un agente.",
chooseProjectHost: "Elige un proyecto y un host",
projectUnavailable: "El proyecto no está disponible en el host seleccionado",
workspacePending: "El espacio de trabajo aún se está creando.",
agentPending: "El agente aún se está creando.",
},
title: "Nuevo espacio de trabajo",
create: "Crear",
isolation: {
-9
View File
@@ -1369,15 +1369,6 @@ export const fr: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Création de l’espace de travail…",
creatingLabel: "Création de l’espace de travail",
updateHost: "Mettez cet hôte à jour pour créer un espace de travail avec un agent.",
chooseProjectHost: "Choisissez un projet et un hôte",
projectUnavailable: "Le projet n’est pas disponible sur l’hôte sélectionné",
workspacePending: "L’espace de travail est en cours de création.",
agentPending: "L’agent est en cours de création.",
},
title: "Nouvel espace de travail",
create: "Créer",
isolation: {
-9
View File
@@ -1353,15 +1353,6 @@ export const ja: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "ワークスペースを作成中…",
creatingLabel: "ワークスペースを作成中",
updateHost: "エージェント付きのワークスペースを作成するには、このホストを更新してください。",
chooseProjectHost: "プロジェクトとホストを選択してください",
projectUnavailable: "選択したホストでプロジェクトを利用できません",
workspacePending: "ワークスペースはまだ作成中です。",
agentPending: "エージェントはまだ作成中です。",
},
title: "新しいワークスペース",
create: "作成",
isolation: {
-9
View File
@@ -1346,15 +1346,6 @@ export const ko: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "워크스페이스 생성 중…",
creatingLabel: "워크스페이스 생성 중",
updateHost: "에이전트가 있는 워크스페이스를 만들려면 이 호스트를 업데이트하세요.",
chooseProjectHost: "프로젝트와 호스트를 선택하세요",
projectUnavailable: "선택한 호스트에서 프로젝트를 사용할 수 없습니다",
workspacePending: "워크스페이스가 아직 생성 중입니다.",
agentPending: "에이전트가 아직 생성 중입니다.",
},
title: "새 워크스페이스",
create: "생성",
isolation: {
-9
View File
@@ -1365,15 +1365,6 @@ export const ptBR: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Criando espaço de trabalho…",
creatingLabel: "Criando espaço de trabalho",
updateHost: "Atualize este host para criar um espaço de trabalho com um agente.",
chooseProjectHost: "Escolha um projeto e um host",
projectUnavailable: "O projeto não está disponível no host selecionado",
workspacePending: "O espaço de trabalho ainda está sendo criado.",
agentPending: "O agente ainda está sendo criado.",
},
title: "Novo workspace",
create: "Criar",
isolation: {
-9
View File
@@ -1357,15 +1357,6 @@ export const ru: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "Создание рабочей области…",
creatingLabel: "Создание рабочей области",
updateHost: "Обновите этот хост, чтобы создать рабочую область с агентом.",
chooseProjectHost: "Выберите проект и хост",
projectUnavailable: "Проект недоступен на выбранном хосте",
workspacePending: "Рабочая область ещё создаётся.",
agentPending: "Агент ещё создаётся.",
},
title: "Новое рабочее пространство",
create: "Создать",
isolation: {
-9
View File
@@ -1328,15 +1328,6 @@ export const zhCN: TranslationResources = {
},
},
newWorkspace: {
creation: {
creatingWorkspace: "正在创建工作区…",
creatingLabel: "正在创建工作区",
updateHost: "请更新此主机,以创建包含代理的工作区。",
chooseProjectHost: "请选择项目和主机",
projectUnavailable: "所选主机上没有此项目",
workspacePending: "工作区仍在创建中。",
agentPending: "代理仍在创建中。",
},
title: "新建 workspace",
create: "创建",
isolation: {
+8 -30
View File
@@ -1,5 +1,3 @@
import { useAgentCreation } from "@/workspace-creation";
import { pendingCreationAgent } from "@/workspace-creation/presentation";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -474,7 +472,6 @@ function AgentPanelContent({
);
});
const creation = useAgentCreation(serverId, agentId);
const connectionServerId = resolvedServerId ?? null;
const daemon = connectionServerId
? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null)
@@ -488,7 +485,7 @@ function AgentPanelContent({
: runtimeConnectionStatus;
const lastConnectionError = runtimeLastError;
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent && !creation)) {
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
return (
<AgentSessionUnavailableState
serverLabel={serverLabel}
@@ -551,8 +548,6 @@ function AgentPanelBody({
const agentState = useSessionStore(
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
);
// A creating agent is known locally before the host can return it.
const isCreating = useAgentCreation(serverId, agentId ?? "") !== null;
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
const lookupAttemptTokenRef = useRef(0);
const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []);
@@ -563,7 +558,7 @@ function AgentPanelBody({
}, [agentId, serverId]);
useEffect(() => {
if (!agentId || isCreating) {
if (!agentId) {
return;
}
if (agentState.id) {
@@ -611,16 +606,7 @@ function AgentPanelBody({
}
setLookupState({ tag: "error", message });
});
}, [
agentId,
agentState.id,
client,
hasSession,
isConnected,
isCreating,
lookupState.tag,
serverId,
]);
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
if (lookupState.tag === "not_found") {
return (
@@ -656,7 +642,7 @@ function AgentPanelBody({
}
: null;
if (!agent && !isCreating) {
if (!agent) {
return (
<View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}>
@@ -769,7 +755,6 @@ function ChatAgentContent({
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
);
const creation = useAgentCreation(serverId, agentId ?? "");
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
kind: "idle",
@@ -827,9 +812,6 @@ function ChatAgentContent({
[agentState, projectPlacement],
);
const continuity = useMemo<AgentScreenContinuity>(() => {
if (creation) {
return { kind: "optimistic-create", agent: pendingCreationAgent(creation) };
}
if (!hasActiveCreateHandoff || !agentId) {
return { kind: "none" };
}
@@ -843,7 +825,7 @@ function ChatAgentContent({
projectPlacement: agent?.projectPlacement ?? null,
},
};
}, [agent, agentId, creation, hasActiveCreateHandoff, serverId]);
}, [agent, agentId, hasActiveCreateHandoff, serverId]);
const viewState = useAgentScreenStateMachine({
routeKey: `${serverId}:${agentId ?? ""}`,
@@ -916,7 +898,7 @@ function ChatAgentContent({
}, [agentId, serverId]);
useEffect(() => {
if (!agentId || creation) {
if (!agentId) {
return;
}
if (agentState.archivedAt) {
@@ -997,7 +979,6 @@ function ChatAgentContent({
agentState.archivedAt,
hasAppliedAuthoritativeHistory,
agentId,
creation,
client,
hasSession,
isConnected,
@@ -1017,8 +998,8 @@ function ChatAgentContent({
if (nonReadyView) return nonReadyView;
invariant(agentId, "agent id is defined when agent content is ready");
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
// Empty while a creating agent waits for the host to choose its directory.
const agentCwd = effectiveAgent.cwd;
const agentCwd = agentState.cwd;
invariant(agentCwd, "agent cwd is defined when agent content is ready");
const showHistorySyncOverlay =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
@@ -1151,7 +1132,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const {
textSource,
editText,
flush,
replaceText,
textReplacement,
attachments,
@@ -1165,7 +1145,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
(): AgentInputDraft => ({
textSource,
editText,
flush,
replaceText,
textReplacement,
attachments,
@@ -1178,7 +1157,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
[
textSource,
editText,
flush,
replaceText,
textReplacement,
attachments,
@@ -10,11 +10,7 @@ import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useAppSettings } from "@/hooks/use-settings";
import { dispatchComposerAgentMessage } from "@/composer/actions";
import { isAgentReadyForPrompts } from "@/workspace-creation";
import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store";
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -81,8 +77,6 @@ export function useReviewFeedback(input: {
await dispatchComposerAgentMessage({
client: targetClient,
agentId: recipient.agentId,
isAgentReady: isAgentReadyForPrompts(serverId, recipient.agentId),
queue: createAgentMessageQueueWriter(serverId),
text: i18n.t("review.feedback.prompt"),
attachments: [attachment],
encodeImages: async () => [],
@@ -21,12 +21,9 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { workspaceCreations } from "@/workspace-creation/runtime";
import { hasActiveMessageSubmission } from "@/composer/submission/model";
import {
HostRuntimeController,
HostRuntimeStore,
getHostRuntimeStore,
readInitialDaemonConnectionHint,
type HostRuntimeControllerDeps,
type HostRuntimeStorage,
@@ -76,8 +73,6 @@ class FakeDaemonClient {
Awaited<ReturnType<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
> = [];
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
public createWorkspaceRequests: Array<Parameters<DaemonClient["createWorkspace"]>[0]> = [];
public createWorkspaceResponse: ReturnType<DaemonClient["createWorkspace"]> | null = null;
public sendAgentMessageFailures: Error[] = [];
public sendAgentMessageResponses: Promise<void>[] = [];
private agentUpdateListeners = new Set<
@@ -124,14 +119,6 @@ class FakeDaemonClient {
}
}
createWorkspace(
request: Parameters<DaemonClient["createWorkspace"]>[0],
): ReturnType<DaemonClient["createWorkspace"]> {
this.createWorkspaceRequests.push(request);
if (!this.createWorkspaceResponse) throw new Error("Unexpected workspace creation");
return this.createWorkspaceResponse;
}
async connect(): Promise<void> {
this.connectCalls += 1;
this.setConnectionState({ status: "connected" });
@@ -439,23 +426,6 @@ function replicaAgent(snapshot: FetchAgentsEntry["agent"], serverId: string): Ag
return { ...normalizeAgentSnapshot(snapshot, serverId), projectPlacement: null };
}
/** Drains only send to an agent the host has published to this client. */
function publishAgent(serverId: string, agentId: string): void {
const entry = makeFetchAgentsEntry({
id: agentId,
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
useSessionStore
.getState()
.setAgents(serverId, new Map([[agentId, replicaAgent(entry.agent, serverId)]]));
}
function queuedTexts(serverId: string, agentId: string): string[] {
const queued = useSessionStore.getState().sessions[serverId]?.queuedMessages.get(agentId) ?? [];
return queued.map((item) => item.text);
}
function agentPermission(id: string): AgentPermissionRequest {
return { id, provider: "codex", name: id, kind: "tool", title: id };
}
@@ -3198,7 +3168,6 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
@@ -3264,7 +3233,6 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.setQueuedMessages(
host.serverId,
new Map([
@@ -3311,7 +3279,6 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.setQueuedMessages(
host.serverId,
new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]),
@@ -3331,212 +3298,6 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("keeps queued messages in order without sending while the agent is unpublished", async () => {
const host = makeHost({ serverId: "srv_unpublished_queue_drain" });
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_unpublished_queue_drain",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setQueuedMessages(
host.serverId,
new Map([
[
"agent",
[
{ id: "first", text: "first follow-up", attachments: [] },
{ id: "second", text: "second follow-up", attachments: [] },
],
],
]),
);
store.drainQueuedAgentMessage(host.serverId, "agent");
await vi.waitFor(() => {
expect(queuedTexts(host.serverId, "agent")).toEqual(["first follow-up", "second follow-up"]);
});
expect(fakeClient.sentAgentMessages).toEqual([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("keeps queued messages in place for a published agent whose creation has not completed", async () => {
// The host publishes a created agent before it starts the initial prompt.
const host = makeHost({ serverId: "srv_creating_queue_drain" });
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_creating_queue_drain",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
const creation = workspaceCreations.submit({
serverId: host.serverId,
key: "creating-queue-drain",
provider: "codex",
config: {},
projectId: "project",
projectName: "Project",
text: "initial prompt",
prepare: () => new Promise(() => undefined),
});
publishAgent(host.serverId, creation.agentId);
const queued = [
{ id: "first", text: "first follow-up", attachments: [] },
{ id: "second", text: "second follow-up", attachments: [] },
];
sessionStore.setQueuedMessages(host.serverId, new Map([[creation.agentId, queued]]));
store.drainQueuedAgentMessage(host.serverId, creation.agentId);
await Promise.resolve();
expect(
useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get(creation.agentId),
).toBe(queued);
expect(fakeClient.sentAgentMessages).toEqual([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("drains a follow-up once when creation completes after the initial turn already ended", async () => {
const host = makeHost({
serverId: "srv_late_creation_adoption",
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
});
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] }));
const completion = new Deferred<Awaited<ReturnType<DaemonClient["createWorkspace"]>>>();
fakeClient.createWorkspaceResponse = completion.promise;
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_late_creation_adoption",
},
});
// Creation and its adoption drain run against the app's runtime.
const previousRuntime = Reflect.get(globalThis, "__paseoHostRuntimeStore");
Reflect.set(globalThis, "__paseoHostRuntimeStore", store);
expect(getHostRuntimeStore()).toBe(store);
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await waitForHostOnline(store, host.serverId);
await store.refreshAgentDirectory({
serverId: host.serverId,
subscribe: {},
page: { limit: 200 },
});
// Connecting replaced the server info; the host tracks submitted prompts.
useSessionStore.getState().updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
version: "0.2.6",
features: { canonicalSubmittedPrompts: true },
});
const creation = workspaceCreations.submit({
serverId: host.serverId,
key: "late-creation-adoption",
provider: "codex",
config: {},
projectId: "project",
projectName: "Project",
text: "initial prompt",
prepare: async () => ({
source: { kind: "directory", path: "/repo" },
agent: { config: { provider: "codex", cwd: "/repo" }, initialPrompt: "initial prompt" },
}),
});
await vi.waitFor(() => expect(fakeClient.createWorkspaceRequests).toHaveLength(1));
useSessionStore
.getState()
.setQueuedMessages(
host.serverId,
new Map([[creation.agentId, [{ id: "follow-up", text: "follow-up", attachments: [] }]]]),
);
// The host publishes the agent, which runs its initial turn to completion before the
// creation response arrives. That turn end finds the agent not ready yet.
const published = makeFetchAgentsEntry({
id: creation.agentId,
cwd: "/repo",
updatedAt: "2026-07-12T10:00:00.000Z",
});
store.acceptWorkspaceSnapshots(host.serverId, [
{
createdAt: null,
lastUserMessageAt: null,
id: creation.workspaceId,
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "repo",
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
},
]);
fakeClient.agentUpdate({
kind: "upsert",
agent: { ...published.agent, status: "running" },
project: published.project,
});
fakeClient.agentUpdate({ kind: "upsert", agent: published.agent, project: published.project });
await Promise.resolve();
expect(fakeClient.sentAgentMessages).toEqual([]);
expect(queuedTexts(host.serverId, creation.agentId)).toEqual(["follow-up"]);
// The initial prompt's local submission has no provider acknowledgement yet.
expect(
hasActiveMessageSubmission(
useSessionStore
.getState()
.sessions[host.serverId]?.messageSubmissions.get(creation.agentId),
),
).toBe(true);
completion.resolve({ workspace: null, error: null } as Awaited<
ReturnType<DaemonClient["createWorkspace"]>
>);
await vi.waitFor(() => expect(fakeClient.sentAgentMessages).toHaveLength(1));
await workspaceCreations.settled(creation);
expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([
[creation.agentId, "follow-up"],
]);
expect(queuedTexts(host.serverId, creation.agentId)).toEqual([]);
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
Reflect.set(globalThis, "__paseoHostRuntimeStore", previousRuntime);
});
it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => {
const host = makeHost({ serverId: "srv_steer_queue_drain" });
const fakeClient = new FakeDaemonClient();
@@ -3553,7 +3314,6 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.setQueuedMessages(
host.serverId,
new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]),
@@ -3582,7 +3342,6 @@ describe("HostRuntimeStore", () => {
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId,
hostname: null,
+8 -12
View File
@@ -73,11 +73,7 @@ import {
import { mountBrowserAutomationDaemonClientHandler } from "@/desktop/browser/automation/handler";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions";
import { isAgentReadyForPrompts } from "@/workspace-creation";
import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
@@ -2572,20 +2568,20 @@ export class HostRuntimeStore {
}
this.queuedAgentDrainInFlight.add(drainKey);
const next = queue[0];
const queueWriter = createAgentMessageQueueWriter(serverId);
void sendQueuedComposerMessageNow({
agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
messageId: next.id,
queue: queueWriter,
queue: {
read: (queuedAgentId) =>
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
},
submitMessage: async ({ text, attachments }) => {
const currentSession = useSessionStore.getState().sessions[serverId];
const supportsForgeAttachments = currentSession?.serverInfo?.features?.forgeSearch === true;
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
await dispatchComposerAgentMessage({
client,
agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
queue: queueWriter,
text,
attachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
@@ -163,25 +163,6 @@ describe("clearPickerPrAttachmentForTargetChange", () => {
});
describe("reducePickerSelection", () => {
it("restores the submitted branch without replacing a newer choice", () => {
const submitted = {
kind: "branch" as const,
name: "recovery-base",
refName: "refs/heads/recovery-base",
accessibilityLabel: "recovery-base, local branch",
};
const restoration = { type: "draft-restored" as const, item: submitted };
expect(reducePickerSelection(initialPickerSelectionState, restoration).selectedItem).toEqual(
submitted,
);
const newer = reducePickerSelection(initialPickerSelectionState, {
type: "picker-selected",
item: { ...submitted, name: "newer-base", refName: "refs/heads/newer-base" },
});
expect(reducePickerSelection(newer, restoration)).toBe(newer);
});
it("selects a PR that was newly detected and added", () => {
const item = { kind: "github-pr" as const, item: makePrItem(101, "A") };
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
@@ -13,7 +13,6 @@ export type PickerSelectionEvent =
| { type: "pr-detected" }
| { type: "pr-added"; item: Extract<PickerItem, { kind: "github-pr" }> }
| { type: "picker-selected"; item: PickerItem }
| { type: "draft-restored"; item: PickerItem | null }
| { type: "target-changed" };
export const initialPickerSelectionState: PickerSelectionState = {
@@ -34,8 +33,6 @@ export function reducePickerSelection(
: state;
case "picker-selected":
return { selectedItem: event.item, allowAutoPrSelection: false };
case "draft-restored":
return state.selectedItem ? state : { selectedItem: event.item, allowAutoPrSelection: false };
case "target-changed":
return initialPickerSelectionState;
}
+380 -53
View File
@@ -1,7 +1,10 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { submitWorkspaceChat, takeWorkspaceCreationFailure } from "@/workspace-creation";
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
@@ -14,6 +17,10 @@ import { Composer } from "@/composer";
import { ComposerDock } from "@/composer/dock";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -45,6 +52,7 @@ import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores
import { useWorkspace } from "@/stores/session-store-hooks";
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import {
useWorkspaceDraftSubmissionStore,
type PendingWorkspaceDraftSetup,
@@ -65,12 +73,19 @@ import {
type HostProjectListItem,
} from "@/projects/host-projects";
import { useProjectIcons } from "@/projects/icons";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
import {
getWorkspaceNamingAttachments,
remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context";
import {
buildPickerOptionData,
defaultBasePickerItem,
@@ -101,8 +116,40 @@ import {
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "./workspace/terminals/state";
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
function useIsNewWorkspaceDraftHandoffActive(input: {
draftId: string | undefined;
selectedServerId: string;
}): boolean {
const normalizedDraftId = input.draftId?.trim() ?? "";
return useCreateFlowStore((state) =>
isActiveCreateFlowForDraft({
draftId: normalizedDraftId,
serverId: input.selectedServerId,
pending: normalizedDraftId ? state.pendingByDraftId[normalizedDraftId] : null,
}),
);
}
function resolveVisibleDraftContextScopeKeys(input: {
isDraftHandoffActive: boolean;
draftContextScopeKey: string;
}): readonly string[] {
if (input.isDraftHandoffActive || !input.draftContextScopeKey) {
return [];
}
return [input.draftContextScopeKey];
}
function isNewWorkspacePending(input: {
pendingAction: "chat" | "empty" | "terminal" | null;
isDraftHandoffActive: boolean;
}): boolean {
return input.pendingAction !== null || input.isDraftHandoffActive;
}
function buildFirstAgentContext(input: {
prompt: string;
attachments: AgentAttachment[];
@@ -191,6 +238,43 @@ function normalizeBranchDetails(
*/
type SubmitOutcome = "navigated" | "background";
interface SubmitDraftInput {
clearConsumedDraft: () => void;
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
initialSetup?: WorkspaceDraftTabSetup;
workspaceId: string;
workspaceDirectory: string;
text: string;
attachments: ComposerAttachment[];
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
}
type NewWorkspaceComposerState = NonNullable<
ReturnType<typeof useAgentInputDraft>["composerState"]
>;
interface WorkspaceDraftSubmissionConfig {
cwd: string;
provider: AgentProvider;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown> | undefined;
target: WorkspaceTabTarget;
}
interface WorkspaceCreationResult {
workspace: ReturnType<typeof normalizeWorkspaceDescriptor>;
agent?: AgentSnapshotPayload;
@@ -253,6 +337,67 @@ async function createMultiplicityWorkspace(input: {
return { workspace: normalizedWorkspace, agent: payload.agent };
}
interface CreateChatAgentInput {
payload: MessagePayload;
composerState: ReturnType<typeof useAgentInputDraft>["composerState"];
forkDraftSetup?: PendingWorkspaceDraftSetup | null;
ensureWorkspace: (input: {
cwd: string;
prompt: string;
attachments: AgentAttachment[];
withInitialAgent: boolean;
agent?: CreateWorkspaceRequestOptions["agent"];
onEvent?: (snapshot: CreationSnapshot) => void;
}) => Promise<WorkspaceCreationResult>;
serverId: string;
draftKey: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
draftId?: string;
draftContextScopeKey: string | null;
supportsForgeSearch: boolean;
resolveClient: () => DaemonClient;
isStillOnCreateScreen: () => boolean;
labels: {
composerStateRequired: string;
selectModel: string;
};
}
function buildWorkspaceDraftSetupFromComposer(input: {
cwd: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
}): WorkspaceDraftTabSetup {
return {
provider: input.provider,
cwd: input.cwd,
modeId: input.composerState.effectiveModeId || null,
model: input.composerState.effectiveModelId || null,
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
featureValues: input.composerState.featureValues ?? {},
};
}
function buildWorkspaceDraftSetupForCreatedWorkspace(input: {
forkDraftSetup: PendingWorkspaceDraftSetup | null | undefined;
workspaceDirectory: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
}): WorkspaceDraftTabSetup | undefined {
if (!input.forkDraftSetup) {
return undefined;
}
return buildWorkspaceDraftSetupFromComposer({
cwd: remapDraftCwdToWorkspace({
cwd: input.forkDraftSetup.setup.cwd,
sourceDirectory: input.forkDraftSetup.sourceDirectory,
workspaceDirectory: input.workspaceDirectory,
}),
provider: input.provider,
composerState: input.composerState,
});
}
function buildComposerInitialValues(input: {
initialSetup?: WorkspaceDraftTabSetup | null;
}): CreateAgentInitialValues | undefined {
@@ -267,6 +412,117 @@ function buildComposerInitialValues(input: {
return undefined;
}
const pendingWorkspaceSubmissions = new Map<string, Promise<SubmitOutcome>>();
function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
const key = JSON.stringify([input.serverId, input.draftId]);
const pending = pendingWorkspaceSubmissions.get(key);
if (pending) return pending;
const submission = Promise.resolve().then(() => createWorkspaceChatAgent(input));
pendingWorkspaceSubmissions.set(key, submission);
void submission.finally(() => pendingWorkspaceSubmissions.delete(key)).catch(() => undefined);
return submission;
}
async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
const { text, attachments, cwd } = payload;
if (!composerState) {
throw new Error(input.labels.composerStateRequired);
}
const provider = composerState.selectedProvider;
if (!provider) {
throw new Error(input.labels.selectModel);
}
const attachmentSubmitFormat = resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: input.supportsForgeSearch,
});
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments, {
format: attachmentSubmitFormat,
});
const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments);
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: attachmentSubmitFormat,
});
const images = await encodeImages(wirePayload.images);
let navigated = false;
let outcome: SubmitOutcome = "background";
const initialAgent: NonNullable<CreateWorkspaceRequestOptions["agent"]> = {
config: {
provider,
cwd,
modeId: composerState.effectiveModeId || undefined,
model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
featureValues: composerState.featureValues,
},
initialPrompt: text,
clientMessageId: `${input.draftId}:initial-message`,
images: images?.length ? images : undefined,
attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined,
};
const execute = async (requestedAgent = initialAgent): Promise<AgentSnapshotPayload> => {
const { agent } = await ensureWorkspace({
cwd,
prompt: text,
attachments: workspaceNamingAttachments,
withInitialAgent: true,
agent: requestedAgent,
onEvent: (snapshot) => {
if (!snapshot.workspace || navigated) return;
navigated = true;
if (!input.isStillOnCreateScreen()) return;
const workspace = normalizeWorkspaceDescriptor(snapshot.workspace);
getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [
{ ...workspace, status: "running" },
]);
const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({
forkDraftSetup: input.forkDraftSetup,
workspaceDirectory: workspace.workspaceDirectory,
provider,
composerState,
});
outcome = submitWorkspaceDraft({
clearConsumedDraft,
draftKey: input.draftKey,
draftContextScopeKey: input.draftContextScopeKey,
resolveClient: input.resolveClient,
isStillOnCreateScreen: input.isStillOnCreateScreen,
serverId,
clearDraft,
draftId: input.draftId,
initialSetup,
workspaceId: workspace.id,
workspaceDirectory: workspace.workspaceDirectory,
text,
attachments,
provider,
composerState,
supportsForgeSearch: input.supportsForgeSearch,
agentCreation,
});
},
});
if (!agent) throw new Error("Workspace creation returned no agent");
return agent;
};
const agentCreation = {
result: Promise.resolve().then(() => execute()),
retry: (request: CreateAgentRequestOptions) =>
execute({
...initialAgent,
config: { ...request.config!, cwd },
initialPrompt: request.initialPrompt ?? "",
clientMessageId: initialAgent.clientMessageId,
images: request.images,
attachments: request.attachments,
}),
};
await agentCreation.result;
if (outcome === "background") clearConsumedDraft();
return outcome;
}
function buildComposerConfig(input: {
serverId: string;
workspaceDirectory: string | null;
@@ -296,6 +552,102 @@ function usePendingWorkspaceDraftSetup(
});
}
function resolveWorkspaceDraftSubmissionConfig(input: {
draftId: string;
workspaceDirectory: string;
provider: AgentProvider;
composerState: NewWorkspaceComposerState;
initialSetup?: WorkspaceDraftTabSetup;
}): WorkspaceDraftSubmissionConfig {
const { draftId, workspaceDirectory, provider, composerState, initialSetup } = input;
if (initialSetup) {
return {
cwd: initialSetup.cwd,
provider: initialSetup.provider,
modeId: initialSetup.modeId,
model: initialSetup.model,
thinkingOptionId: initialSetup.thinkingOptionId,
featureValues: initialSetup.featureValues,
target: { kind: "draft", draftId, setup: initialSetup },
};
}
return {
cwd: workspaceDirectory,
provider,
modeId: composerState.effectiveModeId || null,
model: composerState.effectiveModelId || null,
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
featureValues: composerState.featureValues,
target: { kind: "draft", draftId },
};
}
function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
const {
serverId,
clearDraft,
draftId: draftIdInput,
workspaceId,
workspaceDirectory,
text,
attachments,
provider,
composerState,
initialSetup,
} = input;
const draftId = draftIdInput?.trim() || generateDraftId();
const clientMessageId = `${draftId}:initial-message`;
const timestamp = Date.now();
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: input.supportsForgeSearch,
}),
});
const submission = resolveWorkspaceDraftSubmissionConfig({
draftId,
workspaceDirectory,
provider,
composerState,
initialSetup,
});
const started = useCreateFlowStore.getState().trySetPending({
serverId,
draftId,
workspaceId,
agentId: null,
clientMessageId,
text: text.trim(),
timestamp,
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
});
if (!started) return "navigated";
useWorkspaceDraftSubmissionStore.getState().setPending({
serverId,
workspaceId,
draftId,
text: text.trim(),
attachments,
cwd: submission.cwd,
provider: submission.provider,
clientMessageId,
timestamp,
...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true,
agentCreation: input.agentCreation,
});
clearDraft("sent");
navigateToWorkspace({
serverId,
workspaceId,
target: submission.target,
});
return "navigated";
}
function useNewWorkspaceHostSelector(input: {
initialServerId: string;
allServerIds: string[];
@@ -541,7 +893,7 @@ export function NewWorkspaceScreen({
const [creationResult, setCreationResult] = useState<
WorkspaceCreationResult | { workspace: null }
>({ workspace: null });
const [pendingAction, setPendingAction] = useState<"empty" | "terminal" | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const openAddProjectPicker = useOpenAddProject();
@@ -552,6 +904,7 @@ export function NewWorkspaceScreen({
const projectPickerAnchorRef = useRef<View>(null);
const isolationPickerAnchorRef = useRef<View>(null);
const hostPickerAnchorRef = useRef<View | null>(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
// Launch target: what the composer submits to (chat agent, or a terminal
@@ -634,8 +987,8 @@ export function NewWorkspaceScreen({
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const visibleDraftContextScopeKeys = useMemo(
() => (draftContextScopeKey ? [draftContextScopeKey] : []),
[draftContextScopeKey],
() => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }),
[draftContextScopeKey, isDraftHandoffActive],
);
const chatDraft = useAgentInputDraft({
draftKey,
@@ -653,14 +1006,6 @@ export function NewWorkspaceScreen({
);
const selectedItem = pickerSelection.selectedItem;
useEffect(() => {
const failure = takeWorkspaceCreationFailure(draftKey);
if (!failure) return;
dispatchPickerSelection({ type: "draft-restored", item: failure.selectedItem });
setErrorMessage(failure.error);
toast.error(failure.error);
}, [draftKey, toast]);
const handleForgeChangeRequestDetected = useCallback(() => {
dispatchPickerSelection({ type: "pr-detected" });
}, []);
@@ -689,11 +1034,10 @@ export function NewWorkspaceScreen({
cwd: selectedSourceDirectory ?? "",
});
const flushChatDraft = chatDraft.flush;
const worktreeSupport = selectedProject
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
: "unsupported";
const isPending = pendingAction !== null;
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
useWorkspaceIsolation({
supportsMultiplicity: supportsWorkspaceMultiplicity,
@@ -946,9 +1290,9 @@ export function NewWorkspaceScreen({
async (payload: MessagePayload) => {
try {
setErrorMessage(null);
await composerState?.persistFormPreferences();
await updateFormPreferences({ launchTarget });
if (isEmptyWorkspaceSubmission(payload)) {
await composerState?.persistFormPreferences();
await updateFormPreferences({ launchTarget });
setPendingAction("empty");
let outcome: SubmitOutcome = "background";
await runCreateEmptyWorkspace({
@@ -972,38 +1316,28 @@ export function NewWorkspaceScreen({
return;
}
// Capture the submitted version after publishing any last-keystroke edit.
flushChatDraft();
const attempt = submitWorkspaceChat({
serverId: selectedServerId,
key: creationIdentity.draftId,
returnDraftId: draftId,
project: selectedProject,
sourceDirectory: selectedSourceDirectory,
worktreeSlug: creationIdentity.worktreeSlug,
createsWorktree: !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree",
selectedItem,
setPendingAction("chat");
const outcome = await runCreateChatAgent({
payload,
composerState,
supportsForgeSearch,
forkSetup: forkDraftSetup,
client: withConnectedClient(),
queryClient,
draftId: creationIdentity.draftId,
draftKey,
forkDraftSetup,
ensureWorkspace,
serverId: selectedServerId,
clearDraft: chatDraft.clear,
draftKey,
draftId: creationIdentity.draftId,
draftContextScopeKey,
persistPreferences: async () => {
await composerState?.persistFormPreferences();
await updateFormPreferences({ launchTarget });
supportsForgeSearch,
resolveClient: withConnectedClient,
isStillOnCreateScreen,
labels: {
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
selectModel: t("newWorkspace.errors.selectModel"),
},
});
leaveFullscreen();
navigateToWorkspace({
serverId: selectedServerId,
workspaceId: attempt.workspaceId,
target: { kind: "agent", agentId: attempt.agentId },
});
if (outcome === "background") {
setPendingAction(null);
}
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
@@ -1012,26 +1346,19 @@ export function NewWorkspaceScreen({
}
},
[
draftId,
composerState,
draftContextScopeKey,
creationIdentity,
chatDraft.clear,
flushChatDraft,
draftKey,
ensureWorkspace,
forkDraftSetup,
selectedProject,
selectedSourceDirectory,
selectedItem,
supportsWorkspaceMultiplicity,
effectiveIsolation,
queryClient,
isStillOnCreateScreen,
launchTarget,
leaveFullscreen,
selectedServerId,
supportsForgeSearch,
t,
toast,
updateFormPreferences,
withConnectedClient,
@@ -1,8 +1,12 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { captureWorkspaceDraftCleanup } from "./background-handoff";
import {
captureWorkspaceDraftCleanup,
createWorkspaceAgentInBackground,
} from "./background-handoff";
const DRAFT_ID = "draft_1";
const DRAFT_KEY = "new-workspace";
@@ -28,96 +32,144 @@ function captureCleanup() {
});
}
function setSetup(cwd: string) {
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId: DRAFT_ID,
setup: {
cwd,
provider: "codex",
modeId: null,
model: null,
thinkingOptionId: null,
featureValues: {},
},
});
}
function addContext() {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
function run(createAgent: () => Promise<unknown>, clearConsumedDraft = captureCleanup()) {
return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft });
}
beforeEach(() => {
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} });
useCreateFlowStore.setState({ pendingByDraftId: {} });
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } });
saveDraft("do the thing");
});
describe("captureWorkspaceDraftCleanup", () => {
it("clears the consumed draft setup, attachments and input", () => {
setSetup("/work/repo");
addContext();
const clearConsumedDraft = captureCleanup();
describe("createWorkspaceAgentInBackground", () => {
it("preserves context added to a newer draft without editing its text", async () => {
await run(async () => {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId: DRAFT_ID,
setup: {
cwd: "/work/new-repo",
provider: "codex",
modeId: null,
model: null,
thinkingOptionId: null,
featureValues: {},
},
});
});
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
"/work/new-repo",
);
});
clearConsumedDraft();
it("creates one agent and clears the consumed draft setup, attachments and input", async () => {
let calls = 0;
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId: DRAFT_ID,
setup: {
cwd: "/work/repo",
provider: "claude",
modeId: null,
model: null,
thinkingOptionId: null,
featureValues: {},
},
});
await run(async () => {
calls += 1;
});
expect(calls).toBe(1);
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({});
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined();
expect(draftText()).toBe("");
});
it("preserves newer setup without requiring a text edit", () => {
setSetup("/work/repo");
it("preserves context changed while workspace creation was pending", async () => {
const clearConsumedDraft = captureCleanup();
setSetup("/work/new-repo");
clearConsumedDraft();
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
"/work/new-repo",
);
expect(draftText()).toBe("do the thing");
});
it("preserves context changed while workspace creation was pending", () => {
const clearConsumedDraft = captureCleanup();
addContext();
clearConsumedDraft();
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
await run(async () => undefined, clearConsumedDraft);
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
expect(draftText()).toBe("do the thing");
});
it("keeps a prompt typed into the shared draft while creation was in flight", () => {
const clearConsumedDraft = captureCleanup();
saveDraft("a different idea");
it("leaves no pending submission behind, so no draft tab can create a second agent", async () => {
await run(async () => undefined);
clearConsumedDraft();
expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({});
expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({});
});
it("rethrows a failed creation and keeps the prompt for retry", async () => {
const failure = new Error("daemon exploded");
await expect(
run(async () => {
throw failure;
}),
).rejects.toThrow(failure);
expect(draftText()).toBe("do the thing");
});
it("keeps a prompt typed into the shared draft while creation was in flight", async () => {
await run(async () => {
saveDraft("a different idea");
});
expect(draftText()).toBe("a different idea");
});
it("keeps unchanged context when a newer draft owns it", () => {
addContext();
it("keeps a newer draft written while workspace creation was pending", async () => {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({
scopeKey: SCOPE_KEY,
attachment: {
kind: "chat_history",
id: "fork-context",
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: "Preserve this context",
},
source: { serverId: "local", agentId: "agent-1" },
},
});
const clearConsumedDraft = captureCleanup();
saveDraft("a newer workspace idea");
clearConsumedDraft();
await run(async () => undefined, clearConsumedDraft);
expect(draftText()).toBe("a newer workspace idea");
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
});
@@ -34,3 +34,21 @@ export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput):
input.clearDraft("sent");
};
}
/**
* The workspace draft tab normally issues create_agent once it mounts on the destination screen.
* When the user leaves the New workspace screen before creation resolves, that tab never mounts,
* so this path issues the request itself. The agent tab opens on its own when the workspace is
* next visited — `reconcileTabs` auto-opens agents.
*
* The caller must not write a pending entry to the draft-submission or create-flow stores on this
* path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by
* clientMessageId, so a leftover entry would create a second agent when the workspace is opened.
*/
export async function createWorkspaceAgentInBackground(input: {
clearConsumedDraft: () => void;
createAgent: () => Promise<unknown>;
}): Promise<void> {
await input.createAgent();
input.clearConsumedDraft();
}
@@ -1,4 +1,3 @@
import { useCreationTabMenuEntries } from "@/workspace-creation/ui";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { Keyboard, Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -203,7 +202,7 @@ function MobileWorkspaceTabOption({
[t],
);
const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`;
const rawMenuEntries = buildWorkspaceTabMenuEntries({
const menuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile",
tab,
index: tabIndex,
@@ -222,8 +221,6 @@ function MobileWorkspaceTabOption({
labels: tabMenuLabels,
});
const menuEntries = useCreationTabMenuEntries(normalizedServerId, tab.target, rawMenuEntries);
const fallbackLabels = useMemo(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
@@ -1,4 +1,3 @@
import { isAgentCreationPending } from "@/workspace-creation";
import { useCallback, useState } from "react";
import { type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -47,7 +46,6 @@ export function useWorkspaceTabRename(
}
if (tab.target.kind === "agent") {
const { agentId } = tab.target;
if (isAgentCreationPending(normalizedServerId, agentId)) return;
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const currentTitle = agent?.title ?? "";
@@ -1,4 +1,3 @@
import { useCreationTabMenuEntries } from "@/workspace-creation/ui";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, {
Fragment,
@@ -757,8 +756,7 @@ function TabChip({
onCloseTab: (tabId: string) => Promise<void> | void;
dragHandleProps: DraggableListDragHandleProps | undefined;
}) {
const { closeButtonTestId, contextMenuTestId, menuEntries: rawMenuEntries } = resolvedTab;
const menuEntries = useCreationTabMenuEntries(serverId, tab.target, rawMenuEntries);
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
const { t } = useTranslation();
const openWith = useFilePanelOpenWithMenu(
{ serverId, workspaceId, tabId: tab.tabId },
@@ -173,20 +173,3 @@ describe("resolveWorkspaceRouteState", () => {
).toEqual({ kind: "ready" });
});
});
it.each(["online", "offline"] as const)(
"keeps a reserved workspace usable while %s and directory snapshots omit it",
(connectionStatus) => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus,
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
recovery: { kind: "idle" },
pendingCreation: true,
}),
).toEqual({ kind: "ready" });
},
);
@@ -34,9 +34,7 @@ export function resolveWorkspaceRouteState(input: {
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
recovery: WorkspaceRecoveryModel;
pendingCreation?: boolean;
}): WorkspaceRouteState {
if (input.pendingCreation) return { kind: "ready" };
if (input.workspace) {
if (input.connectionStatus === "online") {
if (input.recovery.kind === "recoverable" && input.recovery.phase === "restoring") {
@@ -1,9 +1,3 @@
import { i18n } from "@/i18n/i18next";
import {
includeCreatingAgent,
workspaceCreationPresentation,
} from "@/workspace-creation/presentation";
import { isAgentCreationPending, useWorkspaceCreation } from "@/workspace-creation";
import { useHostFeature } from "@/runtime/host-features";
import {
AssistantFileLinkResolverProvider,
@@ -650,16 +644,13 @@ function buildWorkspaceHeaderCheckoutState(input: {
function deriveWorkspaceHeaderFields(input: {
workspace: WorkspaceDescriptor | null;
checkoutState: WorkspaceHeaderCheckoutState;
pendingProjectName?: string;
}): WorkspaceHeaderFields {
const renderState = resolveWorkspaceHeaderRenderState(input);
if (renderState.kind !== "ready") {
return {
isWorkspaceHeaderLoading: !input.pendingProjectName,
workspaceHeaderTitle: input.pendingProjectName
? i18n.t("newWorkspace.creation.creatingWorkspace")
: "",
workspaceHeaderSubtitle: input.pendingProjectName ?? "",
isWorkspaceHeaderLoading: true,
workspaceHeaderTitle: "",
workspaceHeaderSubtitle: "",
isWorkspaceHeaderSubtitleDistinct: false,
isGitCheckout: false,
currentBranchName: null,
@@ -722,7 +713,6 @@ function useResolvedWorkspaceRouteState(input: {
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
recovery: WorkspaceRecoveryModel;
pendingCreation: boolean;
}): WorkspaceRouteState {
const hosts = useHosts();
const host = useMemo(
@@ -740,7 +730,6 @@ function useResolvedWorkspaceRouteState(input: {
workspace: input.workspace,
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
recovery: input.recovery,
pendingCreation: input.pendingCreation,
}),
[
hostName,
@@ -749,7 +738,6 @@ function useResolvedWorkspaceRouteState(input: {
input.workspace,
input.hasHydratedWorkspaces,
input.recovery,
input.pendingCreation,
],
);
}
@@ -758,9 +746,7 @@ function shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces: boolean,
workspace: WorkspaceDescriptor | null,
pendingOpenTarget: WorkspaceTabTarget | null,
canInspectCreation: boolean,
): boolean {
if (!canInspectCreation) return false;
return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null;
}
@@ -1049,23 +1035,12 @@ function WorkspaceScreenContent({
[workspaceId],
);
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
const creation = useWorkspaceCreation(normalizedServerId, normalizedWorkspaceId);
const creationPresentation = useMemo(
() => workspaceCreationPresentation(workspaceDescriptor, creation),
[workspaceDescriptor, creation],
);
useEffect(() => {
if (
!normalizedServerId ||
!normalizedWorkspaceId ||
workspaceDescriptor ||
(creation && !creation.workspaceObserved)
)
return;
if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return;
void getHostRuntimeStore()
.prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId)
.catch(() => undefined);
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor, creation]);
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]);
// The focused workspace keeps its script status live even when no sidebar or viewed agent
// timeline demands the directory. Retained, hidden workspaces release it.
useEffect(() => {
@@ -1167,7 +1142,7 @@ function WorkspaceScreenContent({
onOpened: revealWorkspaceTab,
workspaceKey: persistenceKey,
hasHydratedWorkspaces,
workspaceExists: creationPresentation.exists,
workspaceExists: workspaceDescriptor !== null,
});
const workspaceRecovery = useWorkspaceRecovery({
serverId: normalizedServerId,
@@ -1176,7 +1151,6 @@ function WorkspaceScreenContent({
hasHydratedWorkspaces,
workspaceDescriptor,
pendingOpenTarget,
creationPresentation.canInspectRecovery,
),
});
@@ -1188,17 +1162,12 @@ function WorkspaceScreenContent({
}),
[normalizedServerId, normalizedWorkspaceId],
);
const directoryAgentVisibility = useStoreWithEqualityFn(
const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore,
selectWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
);
const workspaceAgentVisibility = useMemo(
() => includeCreatingAgent(directoryAgentVisibility, creation),
[directoryAgentVisibility, creation],
);
const {
handleTerminalCreated,
handleScriptTerminalSelected,
@@ -1264,7 +1233,6 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
recovery: workspaceRecovery.state,
pendingCreation: creationPresentation.workspacePending,
});
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading,
@@ -1281,7 +1249,6 @@ function WorkspaceScreenContent({
} = deriveWorkspaceHeaderFields({
workspace: workspaceDescriptor,
checkoutState: workspaceHeaderCheckoutState,
pendingProjectName: creationPresentation.projectName,
});
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
@@ -1445,7 +1412,7 @@ function WorkspaceScreenContent({
() => isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(),
[isFocusModeEnabled, isMobile],
);
const tabAgentIds = useVisibleAgentIds({
const visibleAgentIds = useVisibleAgentIds({
layout: workspaceLayout,
tabs: uiTabs,
sheetTab,
@@ -1454,13 +1421,6 @@ function WorkspaceScreenContent({
explorerPaneId: explorerSidebarPaneId,
explorerVisible: isExplorerSidebarShowing,
});
const visibleAgentIds = useMemo(
() =>
tabAgentIds.filter(
(agentId) => !creation || creation.agentId !== agentId || creation.agentObserved,
),
[tabAgentIds, creation],
);
useEffect(() => {
for (const agentId of visibleAgentIds) {
void getHostRuntimeStore()
@@ -1874,10 +1834,6 @@ function WorkspaceScreenContent({
const launchWorkspaceTab = useCallback(
(selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => {
if (!workspaceDirectory) {
toast.error(t("newWorkspace.creation.workspacePending"));
return;
}
if (!persistenceKey) {
return;
}
@@ -1909,15 +1865,7 @@ function WorkspaceScreenContent({
const { browserId } = createWorkspaceBrowser();
openTarget({ kind: "browser", browserId });
},
[
createTerminal,
createWorkspaceTab,
persistenceKey,
replaceWorkspaceTabTarget,
workspaceDirectory,
t,
toast,
],
[createTerminal, createWorkspaceTab, persistenceKey, replaceWorkspaceTabTarget],
);
const handleOpenUrlInBrowserTab = useCallback(
@@ -2003,10 +1951,6 @@ function WorkspaceScreenContent({
const handleCloseAgentTab = useCallback(
async (input: { tabId: string; agentId: string }) => {
const { tabId, agentId } = input;
if (isAgentCreationPending(normalizedServerId, agentId)) {
closeWorkspaceTabWithCleanup({ tabId, target: { kind: "agent", agentId } });
return;
}
await closeTab(tabId, async () => {
if (!normalizedServerId) {
return;
@@ -2220,10 +2164,6 @@ function WorkspaceScreenContent({
const handleReloadAgent = useCallback(
async (agentId: string) => {
if (isAgentCreationPending(normalizedServerId, agentId)) {
toast.show(t("newWorkspace.creation.agentPending"));
return;
}
if (!client || !isConnected) {
toast.error(t("workspace.terminal.hostDisconnected"));
return;
@@ -2309,7 +2249,6 @@ function WorkspaceScreenContent({
}
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
if (isAgentCreationPending(normalizedServerId, agentId)) return "layout-only";
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
});
@@ -2341,7 +2280,6 @@ function WorkspaceScreenContent({
groups,
closeTab,
closeLayoutOnlyAgent: async (agentId) => {
if (isAgentCreationPending(normalizedServerId, agentId)) return;
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
-8
View File
@@ -352,14 +352,6 @@ export function selectAgentSentMessages(
return messages;
}
/** Whether the host has published this agent to the client. A creating agent is shown before. */
export function selectIsAgentPublished(
session: SessionState | undefined,
agentId: string,
): boolean {
return Boolean(session?.agents.has(agentId) || session?.agentDetails.has(agentId));
}
export function selectAgentTurnPresentation(
session: SessionState | undefined,
agentId: string,
@@ -45,3 +45,27 @@ test("the production app consumes a draft once and leaves other drafts pending",
expect(store.getState().consumePending(submission)).toBeNull();
expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]);
});
test("consuming draft presentation retains its running creation through remount and retry", () => {
const store = loadProductionStore();
const result = new Promise<never>(() => {});
const creation = { result, retry: () => result };
const submission = {
serverId: "server",
workspaceId: "workspace",
draftId: "draft-one",
text: "hello",
attachments: [],
cwd: "/repo",
provider: "codex",
clientMessageId: "message",
timestamp: 1,
agentCreation: creation,
};
store.getState().setPending(submission);
store.getState().consumePending(submission);
expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined();
expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation);
store.getState().clearDraftSetup({ draftId: submission.draftId });
expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined();
});
@@ -1,9 +1,16 @@
import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
export interface PendingWorkspaceDraftSubmission {
/** Already-running creation. Mounting the draft only observes its result. */
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string;
workspaceId: string;
draftId: string;
@@ -28,6 +35,7 @@ export interface PendingWorkspaceDraftSetup {
interface WorkspaceDraftSubmissionState {
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
creationByDraftId: Record<string, NonNullable<PendingWorkspaceDraftSubmission["agentCreation"]>>;
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
setDraftSetup: (input: {
draftId: string;
@@ -61,8 +69,12 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
(set, get) => ({
pendingByDraftId: {},
setupByDraftId: {},
creationByDraftId: {},
setPending: (submission) =>
set((state) => ({
creationByDraftId: submission.agentCreation
? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation }
: state.creationByDraftId,
pendingByDraftId: {
...state.pendingByDraftId,
[submission.draftId]: submission,
@@ -82,8 +94,10 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
const normalizedDraftId = normalizeDraftId(draftId);
if (!normalizedDraftId) return;
set((state) => {
const creationByDraftId = { ...state.creationByDraftId };
delete creationByDraftId[normalizedDraftId];
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
return { setupByDraftId };
return { setupByDraftId, creationByDraftId };
});
},
consumePending: (input) => {
@@ -271,6 +271,7 @@ export interface WorkspaceTabSnapshot {
terminalsHydrated: boolean;
activeAgentIds: Iterable<string>;
autoOpenAgentIds: Iterable<string>;
foreignAgentIds?: Iterable<string>;
knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>;
hasActivePendingTerminalCreate?: boolean;
@@ -2443,13 +2444,16 @@ export function reconcileWorkspaceTabs(
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
// An explicit open owns the tab until the agent joins the active directory.
// From then on it follows the normal server archive lifecycle. Detail/cache
// hydration never decides whether the user's target is allowed to stay open.
const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []);
// Explicit opens survive absent directory entries, but not known foreign ownership.
// Once an agent joins this workspace's active directory, normal archive rules apply.
const pinnedAgentIds = new Set(
[...(state.pinnedAgentIds ?? [])].filter(
(agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId),
),
[...(state.pinnedAgentIds ?? [])].filter((agentId) => {
if (!snapshot.agentsHydrated) {
return true;
}
return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId);
}),
);
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const knownTerminalIds = snapshot.knownTerminalIds
@@ -1,9 +0,0 @@
export {
useWorkspaceCreations,
useWorkspaceCreation,
useAgentCreation,
isAgentCreationPending,
isAgentReadyForPrompts,
useIsAgentReadyForPrompts,
} from "./runtime";
export { submitWorkspaceChat, takeWorkspaceCreationFailure } from "./submit";
@@ -1,12 +0,0 @@
/** Creation input is plain data; copy it without relying on browser-only structuredClone. */
export function captureCreationInput<T>(value: T): T;
export function captureCreationInput(value: unknown): unknown {
if (value instanceof Date) return new Date(value.getTime());
if (Array.isArray(value)) return value.map(captureCreationInput);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, captureCreationInput(item)]),
);
}
return value;
}
@@ -1,342 +0,0 @@
import { describe, expect, it } from "vitest";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import { createWorkspaceCreationModel } from "../model";
describe("optimistic workspace creation", () => {
it("publishes final identities and immutable input before preparing or receiving anything", async () => {
let prepare!: () => void;
const held = new Promise<void>((resolve) => {
prepare = resolve;
});
const requests: unknown[] = [];
const failures: string[] = [];
const model = createWorkspaceCreationModel({
identities: () => ({
operationKey: "operation",
workspaceId: "wks_0123456789abcdef",
agentId: "agent",
clientMessageId: "message",
}),
execute: async (request) => {
requests.push(request);
return { workspace: null, error: "test failure" };
},
});
const input = {
serverId: "host",
key: "draft",
provider: "codex" as const,
config: {},
projectId: "project",
projectName: "Project",
text: "My prompt",
onWorkspaceFailed: (_attempt: unknown, error: string) => failures.push(error),
prepare: async () => {
await held;
return {
source: { kind: "directory" as const, path: "/project" },
agent: {
config: { provider: "codex" as const, cwd: "/project" },
initialPrompt: "My prompt",
},
};
},
};
const attempt = model.submit(input);
expect(attempt.workspaceId).toBe("wks_0123456789abcdef");
expect(model.getWorkspace("host", attempt.workspaceId)?.text).toBe("My prompt");
expect(model.submit(input)).toBe(attempt);
expect(requests).toEqual([]);
prepare();
await model.settled(attempt);
expect(requests).toHaveLength(1);
expect(failures).toEqual(["test failure"]);
expect(model.getWorkspace("host", attempt.workspaceId)).toBeNull();
});
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function submission() {
return {
serverId: "host",
key: "draft-version-1",
provider: "codex" as const,
config: {},
projectId: "project",
projectName: "Project",
text: "Original prompt",
prepare: async () => ({
source: { kind: "directory" as const, path: "/project" },
agent: {
config: {
provider: "codex" as const,
cwd: "/project/nested",
featureValues: { option: true },
},
initialPrompt: "Original prompt",
attachments: [
{
type: "text" as const,
mimeType: "text/plain" as const,
text: "Attachment content",
title: "Context",
},
],
},
}),
};
}
function identities() {
return {
operationKey: "operation",
workspaceId: "wks_0123456789abcdef",
agentId: "agent",
clientMessageId: "message",
};
}
/** A host receipt; the workspace and agent payloads only need to be present. */
function failedCreation(update: object): CreationSnapshot {
return {
kind: "workspace",
idempotencyKey: "operation",
revision: 3,
phase: "failed",
workspaceId: identities().workspaceId,
agentId: "agent",
error: "Provider startup failed",
...update,
} as CreationSnapshot;
}
describe("creation lifecycle ownership", () => {
it.each([
["the transport closes before any receipt", { throws: "Client closed" }, "workspace"],
[
"provisioning fails",
{ creation: { failedStage: "workspace", outcomeUnknown: true } },
"workspace",
],
["agent creation fails after provisioning", { creation: { failedStage: "agent" } }, "agent"],
[
"the transport closes after the workspace was published",
{ throws: "Client closed", event: { phase: "workspace_ready", workspace: {} } },
"agent",
],
[
"the agent may exist",
{ creation: { failedStage: "agent", outcomeUnknown: true } },
"complete",
],
[
"the first prompt fails on a created agent",
{ creation: { failedStage: "prompt", outcomeUnknown: true } },
"complete",
],
] as const)("when %s, hands the creation to its %s owner", async (_name, outcome, owner) => {
const handled: string[] = [];
let executions = 0;
const model = createWorkspaceCreationModel({
identities,
execute: async (request) => {
executions++;
if ("event" in outcome)
request.onEvent?.(failedCreation({ ...outcome.event, revision: 1 }));
if ("throws" in outcome) throw new Error(outcome.throws);
return { error: "Provider startup failed", creation: failedCreation(outcome.creation) };
},
onFailed: () => handled.push("prompt rejected"),
});
const attempt = model.submit({
...submission(),
onWorkspaceFailed: () => handled.push("workspace"),
onAgentFailed: () => handled.push("agent"),
onComplete: () => handled.push("complete"),
});
await model.settled(attempt);
expect(handled).toEqual(owner === "complete" ? ["complete"] : ["prompt rejected", owner]);
// A kept agent stays presented until the host publishes it; a failure leaves at once.
expect(model.getWorkspace("host", attempt.workspaceId)?.phase ?? null).toBe(
owner === "complete" ? "complete" : null,
);
expect(executions).toBe(1);
});
it("returns a preparation failure to New workspace without any host execution", async () => {
let executions = 0;
const failures: string[] = [];
const model = createWorkspaceCreationModel({
identities,
execute: async () => {
executions++;
return {};
},
});
const attempt = model.submit({
...submission(),
prepare: async () => {
throw new Error("Image unavailable");
},
onWorkspaceFailed: (_attempt, error) => failures.push(error),
});
await model.settled(attempt);
expect(failures).toEqual(["Image unavailable"]);
expect(executions).toBe(0);
});
it("reports completion and adoption once", async () => {
const lifecycle: string[] = [];
const model = createWorkspaceCreationModel({
identities,
execute: async () => ({}),
onSubmitted: (attempt) => lifecycle.push(`submitted:${attempt.phase}`),
onComplete: (attempt) => lifecycle.push(`complete:${attempt.phase}`),
onAdopted: (attempt) => lifecycle.push(`adopted:${attempt.phase}`),
});
const attempt = model.submit(submission());
await model.settled(attempt);
model.observeResources("host", new Set([attempt.workspaceId]), new Set());
expect(lifecycle).toEqual(["submitted:preparing", "complete:complete"]);
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
expect(lifecycle).toEqual(["submitted:preparing", "complete:complete", "adopted:complete"]);
});
it("keeps presentation stable across receipt updates and empty directory snapshots", async () => {
const response = deferred<{}>();
const observed =
deferred<
NonNullable<
Parameters<Parameters<typeof createWorkspaceCreationModel>[0]["execute"]>[0]["onEvent"]
>
>();
const model = createWorkspaceCreationModel({
identities,
execute: async (request) => {
observed.resolve(request.onEvent!);
return response.promise;
},
});
const attempt = model.submit(submission());
const onEvent = await observed.promise;
const event = {
kind: "workspace" as const,
idempotencyKey: "operation",
workspaceId: attempt.workspaceId,
agentId: attempt.agentId,
revision: 3,
phase: "workspace_ready" as const,
error: null,
};
const pendingSnapshot = model.getSnapshot();
let publications = 0;
const stop = model.subscribe(() => publications++);
onEvent({ ...event, revision: 1, phase: "accepted" });
onEvent(event);
expect(model.getSnapshot()).toBe(pendingSnapshot);
expect(publications).toBe(0);
stop();
model.observeResources("host", new Set(), new Set());
expect(model.getWorkspace("host", attempt.workspaceId)).toMatchObject({
workspaceObserved: false,
agentObserved: false,
});
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
model.observeResources("host", new Set(), new Set());
response.resolve({});
await model.settled(attempt);
expect(model.getWorkspace("host", attempt.workspaceId)).toBeNull();
});
it("captures presentation without retaining caller mutation and permits a distinct newer intent", async () => {
const response = deferred<{}>();
let next = 0;
const model = createWorkspaceCreationModel({
identities: () => ({ ...identities(), agentId: `agent-${++next}` }),
execute: async () => response.promise,
});
const original = submission();
const first = model.submit(original);
original.text = "Newer draft";
const second = model.submit({ ...original, key: "draft-version-2" });
expect(first.text).toBe("Original prompt");
expect(second.text).toBe("Newer draft");
expect(second.agentId).not.toBe(first.agentId);
response.resolve({});
await model.settled(first);
await model.settled(second);
});
});
describe("settled presentation lifetime", () => {
it("retires adopted presentation while repeated submission still returns its original identities", async () => {
let executions = 0;
const model = createWorkspaceCreationModel({
identities,
execute: async () => {
executions++;
return {};
},
});
const input = submission();
const attempt = model.submit(input);
await model.settled(attempt);
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
expect(model.getSnapshot()).toEqual([]);
const repeated = model.submit(input);
expect(repeated.workspaceId).toBe(attempt.workspaceId);
expect(repeated.agentId).toBe(attempt.agentId);
await model.settled(repeated);
expect(executions).toBe(1);
expect(repeated.text).toBe("");
expect(repeated.message).toBeUndefined();
});
});
it("bounds adopted receipts and keeps in-flight input outside that bound", async () => {
let executions = 0;
let identity = 0;
const held = deferred<{}>();
const model = createWorkspaceCreationModel({
identities: () => ({
...identities(),
workspaceId: `workspace-${++identity}`,
agentId: `agent-${identity}`,
}),
execute: async (request) => {
executions++;
return request.agent?.initialPrompt === "held" ? held.promise : {};
},
});
const pending = model.submit({
...submission(),
key: "pending",
prepare: async () => ({
source: { kind: "directory", path: "/project" },
agent: { provider: "codex", cwd: "/project", initialPrompt: "held" },
}),
});
const first = { ...submission(), key: "settled-0" };
let latest = first;
for (let n = 0; n < 130; n++) {
latest = { ...submission(), key: `settled-${n}` };
const attempt = model.submit(latest);
await model.settled(attempt);
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
}
expect(model.getSnapshot()).toHaveLength(1);
expect(model.getSnapshot()[0]?.text).toBe("Original prompt");
const before = executions;
await model.settled(model.submit(latest));
expect(executions).toBe(before);
await model.settled(model.submit(first));
expect(executions).toBe(before + 1);
held.resolve({});
await model.settled(pending);
});
@@ -1,159 +0,0 @@
import { describe, expect, it } from "vitest";
import { createWorkspaceCreationModel } from "../model";
import {
includeCreatingAgent,
pendingCreationAgent,
workspaceCreationPresentation,
} from "../presentation";
import { addCreatingSidebarWorkspaces } from "../presentation";
import {
buildWorkspaceTabSnapshot,
deriveWorkspaceAgentVisibility,
} from "@/workspace-tabs/agent-visibility";
import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { SidebarWorkspacePlacementModel } from "@/hooks/sidebar-workspaces-view-model";
function pendingCreation() {
let finish!: () => void;
const response = new Promise<{}>((resolve) => {
finish = () => resolve({});
});
const model = createWorkspaceCreationModel({
identities: () => ({
operationKey: "operation",
workspaceId: "wks_0123456789abcdef",
agentId: "reserved-agent",
clientMessageId: "message",
}),
execute: async () => response,
});
const attempt = model.submit({
serverId: "host",
key: "draft",
projectId: "project",
projectName: "Project",
provider: "mock",
config: { model: "e2e-fast-stream", modeId: "load-test" },
text: "My pending prompt",
prepare: async () => ({
source: { kind: "directory", path: "/project" },
agent: { config: { provider: "mock", cwd: "/project" }, initialPrompt: "My pending prompt" },
}),
});
return { model, attempt, finish };
}
describe("creation presentation across directory replacement", () => {
it("keeps the final agent tab through empty snapshots and releases it to ordinary archive rules after completion", async () => {
const { model, attempt, finish } = pendingCreation();
let next = 0;
const layouts = createWorkspaceLayoutStore({
createNodeId: (prefix) => `${prefix}_${++next}`,
createFocusRestorationToken: () => `focus-${++next}`,
});
const workspaceKey = `host:${attempt.workspaceId}`;
const tabId = layouts.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId: attempt.agentId },
intent: "reveal",
});
const reconcileEmptyDirectory = () =>
layouts.getState().reconcileTabs(
workspaceKey,
buildWorkspaceTabSnapshot({
agentVisibility: includeCreatingAgent(
deriveWorkspaceAgentVisibility({
sessionAgents: new Map(),
workspaceId: attempt.workspaceId,
}),
model.getWorkspace("host", attempt.workspaceId),
),
agentsHydrated: true,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
}),
);
reconcileEmptyDirectory();
model.observeResources("host", new Set([attempt.workspaceId]), new Set([attempt.agentId]));
reconcileEmptyDirectory();
expect(
layouts
.getState()
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.tabId),
).toEqual([tabId]);
finish();
await model.settled(attempt);
reconcileEmptyDirectory();
expect(
layouts
.getState()
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent"),
).toEqual([]);
});
it("offers pending navigation without inventing an operational workspace directory", () => {
const { attempt, finish } = pendingCreation();
expect(workspaceCreationPresentation(null, attempt)).toEqual({
workspacePending: true,
exists: true,
projectName: "Project",
canInspectRecovery: false,
});
expect(attempt).not.toHaveProperty("workspaceDirectory");
expect(attempt).not.toHaveProperty("capabilities");
// The submitted config is displayable, but the source directory is not the destination.
expect(pendingCreationAgent(attempt)).toMatchObject({
id: "reserved-agent",
provider: "mock",
model: "e2e-fast-stream",
currentModeId: "load-test",
cwd: "",
status: "running",
});
finish();
});
it("keeps one sidebar entry through refresh and hands it to the authoritative directory", async () => {
const { model, attempt, finish } = pendingCreation();
const directory: SidebarWorkspacePlacementModel = {
projects: [
{
viewKey: "project",
projectName: "Project",
projectKind: "git",
iconWorkingDir: "/project",
hosts: [
{
serverId: "host",
projectId: "project",
iconWorkingDir: "/project",
worktreeSupport: "supported",
},
],
workspaces: [],
},
],
workspaces: [],
projectNamesByViewKey: new Map([["project", "Project"]]),
};
const first = addCreatingSidebarWorkspaces(directory, model.getSnapshot());
const refreshed = addCreatingSidebarWorkspaces(
{ ...directory, workspaces: [] },
model.getSnapshot(),
);
expect(first.workspaces).toEqual(refreshed.workspaces);
expect(refreshed.workspaces).toHaveLength(1);
expect(refreshed.workspaces[0]?.workspaceId).toBe(attempt.workspaceId);
expect(refreshed.workspaces[0]?.workspaceDirectory).toBeUndefined();
model.observeResources("host", new Set([attempt.workspaceId]), new Set());
expect(addCreatingSidebarWorkspaces(first, model.getSnapshot()).workspaces).toHaveLength(1);
finish();
await model.settled(attempt);
});
});
@@ -1 +0,0 @@
export { randomUUID } from "expo-crypto";
@@ -1,3 +0,0 @@
export function randomUUID(): string {
return crypto.randomUUID();
}
@@ -1,238 +0,0 @@
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
import { captureCreationInput } from "../internal/capture";
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import type { UserMessageItem } from "@/types/stream";
export interface Submission {
serverId: string;
key: string;
provider: AgentProvider;
/** Submitted agent configuration, shown before the host publishes the agent. */
config: { modeId?: string; model?: string; thinkingOptionId?: string };
projectId: string;
projectName: string;
text: string;
message?: Omit<UserMessageItem, "id" | "clientMessageId">;
prepare: () => Promise<CreateWorkspaceRequestOptions>;
onComplete?: () => void;
/** The host created neither the workspace nor the agent. */
onWorkspaceFailed?: (attempt: CreationAttempt, error: string) => void;
/** The host created the workspace but not the agent. The receipt's workspace, when it has one. */
onAgentFailed?: (
attempt: CreationAttempt,
error: string,
workspace: CreationSnapshot["workspace"],
) => void;
}
type SubmissionHandlers = "prepare" | "onComplete" | "onWorkspaceFailed" | "onAgentFailed";
export interface CreationAttempt extends Omit<Submission, SubmissionHandlers> {
operationKey: string;
workspaceId: string;
agentId: string;
clientMessageId: string;
phase: "preparing" | "creating" | "complete";
workspaceObserved: boolean;
agentObserved: boolean;
}
interface Result {
workspace?: unknown;
error?: string | null;
creation?: CreationSnapshot;
}
interface Operation extends Pick<Submission, "onComplete" | "onWorkspaceFailed" | "onAgentFailed"> {
attempt: CreationAttempt;
/** The host's latest account of this creation. */
creation?: CreationSnapshot;
running?: Promise<void>;
}
/** What the host is known to have created for a failed creation. */
function createdResources(
attempt: CreationAttempt,
creation: CreationSnapshot | undefined,
): "agent" | "workspace" | "none" {
if (
attempt.agentObserved ||
creation?.agent ||
creation?.failedStage === "prompt" ||
(creation?.failedStage === "agent" && creation.outcomeUnknown)
)
return "agent";
if (attempt.workspaceObserved || creation?.workspace || creation?.failedStage === "agent")
return "workspace";
return "none";
}
export function createWorkspaceCreationModel(dependencies: {
identities: () => Pick<
CreationAttempt,
"operationKey" | "workspaceId" | "agentId" | "clientMessageId"
>;
execute: (request: CreateWorkspaceRequestOptions, serverId: string) => Promise<Result>;
onSubmitted?: (attempt: CreationAttempt) => void;
/** The host did not create the agent; its prompt was never delivered. */
onFailed?: (attempt: CreationAttempt) => void;
onComplete?: (attempt: CreationAttempt) => void;
/** The completed creation's workspace and agent are both published. */
onAdopted?: (attempt: CreationAttempt) => void;
}) {
const listeners = new Set<() => void>();
let snapshot: readonly CreationAttempt[] = [];
const operations = new Map<string, Operation>();
// Current submissions remain owned until settled and adopted. Adopted creations
// only need a bounded receipt to absorb duplicate commands from retained forms.
const receipts = new Map<string, CreationAttempt>();
const intentKey = (serverId: string, key: string) =>
bytesToHex(sha256(utf8ToBytes(JSON.stringify([serverId, key]))));
const operationFor = (attempt: CreationAttempt) => operations.get(attempt.key);
function retire(operation: Operation) {
if (
operation.attempt.phase !== "complete" ||
!operation.attempt.workspaceObserved ||
!operation.attempt.agentObserved
)
return;
const receipt = { ...operation.attempt, text: "", message: undefined, projectName: "" };
receipts.set(receipt.key, receipt);
while (receipts.size > 128) receipts.delete(receipts.keys().next().value!);
operations.delete(receipt.key);
publish();
dependencies.onAdopted?.(operation.attempt);
}
function publish() {
snapshot = [...operations.values()].map(({ attempt }) => attempt);
listeners.forEach((listener) => listener());
}
function patch(
operation: NonNullable<ReturnType<typeof operations.get>>,
update: Partial<CreationAttempt>,
) {
operation.attempt = { ...operation.attempt, ...update };
publish();
}
function complete(operation: Operation) {
patch(operation, { phase: "complete" });
dependencies.onComplete?.(operation.attempt);
const onComplete = operation.onComplete;
operation.onComplete = undefined;
operation.onWorkspaceFailed = undefined;
operation.onAgentFailed = undefined;
onComplete?.();
retire(operation);
}
/** Hands a failure to the owner of what the host created: the agent, the workspace or nothing. */
function fail(operation: Operation, error: string) {
const created = createdResources(operation.attempt, operation.creation);
if (created === "agent") {
complete(operation);
return;
}
operations.delete(operation.attempt.key);
publish();
dependencies.onFailed?.(operation.attempt);
if (created === "workspace")
operation.onAgentFailed?.(operation.attempt, error, operation.creation?.workspace);
else operation.onWorkspaceFailed?.(operation.attempt, error);
}
async function run(operation: Operation, prepare: Submission["prepare"]) {
try {
const prepared = await prepare();
const request = captureCreationInput({
...prepared,
workspaceId: operation.attempt.workspaceId,
idempotencyKey: operation.attempt.operationKey,
agent: {
...prepared.agent,
agentId: operation.attempt.agentId,
clientMessageId: operation.attempt.clientMessageId,
},
});
patch(operation, { phase: "creating" });
const result = await dependencies.execute(
{
...request,
// CreationClient owns receipt ordering; progress alone does not change presentation.
onEvent: (event) => {
operation.creation = event;
},
},
operation.attempt.serverId,
);
if (result.creation) operation.creation = result.creation;
if (result.error) {
fail(operation, result.error);
return;
}
complete(operation);
} catch (error) {
fail(operation, error instanceof Error ? error.message : String(error));
} finally {
operation.running = undefined;
}
}
function start(operation: Operation, prepare: Submission["prepare"]) {
operation.running = Promise.resolve().then(() => run(operation, prepare));
}
return {
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot: () => snapshot,
submit(input: Submission): CreationAttempt {
const key = intentKey(input.serverId, input.key);
const existing = operations.get(key);
if (existing) return existing.attempt;
const receipt = receipts.get(key);
if (receipt) return receipt;
const { prepare, onComplete, onWorkspaceFailed, onAgentFailed, ...presentation } = input;
const attempt: CreationAttempt = {
...captureCreationInput(presentation),
key,
...dependencies.identities(),
phase: "preparing",
workspaceObserved: false,
agentObserved: false,
};
const operation: Operation = {
onComplete,
onWorkspaceFailed,
onAgentFailed,
attempt,
};
operations.set(key, operation);
dependencies.onSubmitted?.(operation.attempt);
publish();
start(operation, prepare);
return operation.attempt;
},
observeResources(
serverId: string,
workspaces: ReadonlySet<string>,
agents: ReadonlySet<string>,
) {
for (const operation of operations.values()) {
const attempt = operation.attempt;
if (attempt.serverId !== serverId) continue;
const workspaceObserved = attempt.workspaceObserved || workspaces.has(attempt.workspaceId);
const agentObserved = attempt.agentObserved || agents.has(attempt.agentId);
if (
workspaceObserved !== attempt.workspaceObserved ||
agentObserved !== attempt.agentObserved
)
patch(operation, { workspaceObserved, agentObserved });
retire(operation);
}
},
getWorkspace: (serverId: string, workspaceId: string) =>
snapshot.find((item) => item.serverId === serverId && item.workspaceId === workspaceId) ??
null,
getAgent: (serverId: string, agentId: string) =>
snapshot.find((item) => item.serverId === serverId && item.agentId === agentId) ?? null,
settled: (attempt: CreationAttempt) => operationFor(attempt)?.running ?? Promise.resolve(),
};
}
@@ -1,113 +0,0 @@
import { i18n } from "@/i18n/i18next";
import type {
SidebarWorkspaceEntry,
SidebarWorkspacePlacement,
SidebarWorkspacePlacementModel,
} from "@/hooks/sidebar-workspaces-view-model";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility";
import type { CreationAttempt } from "../model";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
export function workspaceCreationPresentation(
workspace: WorkspaceDescriptor | null,
attempt: CreationAttempt | null,
) {
const workspacePending = attempt !== null && !attempt.workspaceObserved;
return {
workspacePending,
exists: workspace !== null || attempt !== null,
projectName: attempt?.projectName,
canInspectRecovery: !workspacePending,
};
}
export function includeCreatingAgent(
visibility: WorkspaceAgentVisibility,
attempt: CreationAttempt | null,
): WorkspaceAgentVisibility {
if (!attempt) return visibility;
if (attempt.phase === "complete" && attempt.agentObserved) return visibility;
return {
activeAgentIds: new Set([...visibility.activeAgentIds, attempt.agentId]),
autoOpenAgentIds: new Set([...visibility.autoOpenAgentIds, attempt.agentId]),
};
}
/** The agent as submitted, until the host publishes it. Its destination cwd is not known yet. */
export function pendingCreationAgent(attempt: CreationAttempt): AgentScreenAgent {
return {
serverId: attempt.serverId,
id: attempt.agentId,
provider: attempt.provider,
status: "running",
cwd: "",
workspaceId: attempt.workspaceId,
currentModeId: attempt.config.modeId ?? null,
model: attempt.config.model ?? null,
thinkingOptionId: attempt.config.thinkingOptionId ?? null,
projectPlacement: null,
};
}
export function addCreatingSidebarWorkspaces(
model: SidebarWorkspacePlacementModel,
attempts: readonly CreationAttempt[],
translate = i18n.t,
): SidebarWorkspacePlacementModel {
const pending = attempts.filter((attempt) => !attempt.workspaceObserved);
if (!pending.length) return model;
const projects = model.projects.map((project) => {
const creations = pending.filter((attempt) =>
project.hosts.some(
(host) => host.serverId === attempt.serverId && host.projectId === attempt.projectId,
),
);
if (!creations.length) return project;
const workspaces = [...project.workspaces];
for (const attempt of creations) {
if (
workspaces.some(
(workspace) =>
workspace.serverId === attempt.serverId &&
workspace.workspaceId === attempt.workspaceId,
)
)
continue;
workspaces.push({
workspaceKey: `${attempt.serverId}:${attempt.workspaceId}`,
serverId: attempt.serverId,
workspaceId: attempt.workspaceId,
projectViewKey: project.viewKey,
projectName: project.projectName,
projectKind: project.projectKind,
workspaceKind: "checkout",
name: translate("newWorkspace.creation.creatingWorkspace"),
});
}
return { ...project, workspaces };
});
return { ...model, projects, workspaces: projects.flatMap((project) => project.workspaces) };
}
export function creatingSidebarEntry(
placement: SidebarWorkspacePlacement,
translate = i18n.t,
): SidebarWorkspaceEntry {
return {
...placement,
name: translate("newWorkspace.creation.creatingWorkspace"),
workspaceDirectory: "",
workspaceDirectoryLabel: "",
title: null,
currentBranch: null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
statusBucket: "running",
statusEnteredAt: null,
};
}
@@ -1,146 +0,0 @@
import { i18n } from "@/i18n/i18next";
import { selectIsAgentPublished, useSessionStore } from "@/stores/session-store";
import { randomUUID } from "./internal/random-id";
import { useSyncExternalStore } from "react";
import { createWorkspaceCreationModel, type CreationAttempt } from "./model";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { createUserMessage } from "@/types/stream";
export const workspaceCreations = createWorkspaceCreationModel({
identities: () => ({
operationKey: randomUUID(),
workspaceId: `wks_${randomUUID().replaceAll("-", "").slice(0, 16)}`,
agentId: randomUUID(),
clientMessageId: randomUUID(),
}),
execute: async (request, serverId) => {
const { getHostRuntimeStore } = await import("@/runtime/host-runtime");
const runtime = getHostRuntimeStore();
const client = runtime.getClient(serverId);
if (!client) throw new Error(i18n.t("newWorkspace.errors.hostDisconnected"));
const workspaceId = request.workspaceId;
if (!workspaceId) throw new Error("Workspace creation requires a reserved identity");
const releaseDirectory = runtime.acquireDirectoryDemand(serverId);
// Failed and adopted creations both leave the model.
const stop = workspaceCreations.subscribe(() => {
if (workspaceCreations.getWorkspace(serverId, workspaceId)) return;
stop();
releaseDirectory();
});
return client.createWorkspace(request);
},
onSubmitted: (attempt) => {
createMessageSubmissionWriter(attempt.serverId).begin(
attempt.agentId,
createUserMessage({
...attempt.message,
text: attempt.text,
timestamp: attempt.message?.timestamp ?? new Date(),
id: attempt.clientMessageId,
clientMessageId: attempt.clientMessageId,
}),
);
},
onFailed: (attempt) => {
createMessageSubmissionWriter(attempt.serverId).reject(
attempt.agentId,
attempt.clientMessageId,
);
},
onAdopted: (attempt) => {
// Prompts queued during creation waited for this. A running agent drains them when it stops.
// The host started the initial prompt before completing, so its local submission is not
// evidence of a running turn; only the host's turn state is.
const session = useSessionStore.getState().sessions[attempt.serverId];
const agent =
session?.agents.get(attempt.agentId) ?? session?.agentDetails.get(attempt.agentId);
if (agent?.turn.phase !== "idle") return;
void import("@/runtime/host-runtime").then(({ getHostRuntimeStore }) =>
getHostRuntimeStore().drainQueuedAgentMessage(attempt.serverId, attempt.agentId),
);
},
onComplete: (attempt) => {
createMessageSubmissionWriter(attempt.serverId).accept(
attempt.agentId,
attempt.clientMessageId,
);
},
});
export function useWorkspaceCreations() {
return useSyncExternalStore(
workspaceCreations.subscribe,
workspaceCreations.getSnapshot,
workspaceCreations.getSnapshot,
);
}
export function useWorkspaceCreation(
serverId: string,
workspaceId: string,
): CreationAttempt | null {
return (
useWorkspaceCreations().find(
(attempt) =>
attempt.serverId === serverId &&
attempt.workspaceId === workspaceId &&
(attempt.phase !== "complete" || !attempt.workspaceObserved || !attempt.agentObserved),
) ?? null
);
}
export function useAgentCreation(serverId: string, agentId: string): CreationAttempt | null {
return (
useWorkspaceCreations().find(
(attempt) =>
attempt.serverId === serverId &&
attempt.agentId === agentId &&
(attempt.phase !== "complete" || !attempt.agentObserved),
) ?? null
);
}
useSessionStore.subscribe((state, previous) => {
for (const serverId of new Set(
workspaceCreations
.getSnapshot()
.filter((attempt) => !attempt.workspaceObserved || !attempt.agentObserved)
.map((attempt) => attempt.serverId),
)) {
const session = state.sessions[serverId];
const prior = previous.sessions[serverId];
if (
!session ||
(session.workspaces === prior?.workspaces &&
session.agents === prior?.agents &&
session.agentDetails === prior?.agentDetails)
)
continue;
workspaceCreations.observeResources(
serverId,
new Set([...session.workspaces.values()].map((workspace) => workspace.id)),
new Set([...session.agents.keys(), ...session.agentDetails.keys()]),
);
}
});
export function isAgentCreationPending(serverId: string, agentId: string): boolean {
const attempt = workspaceCreations.getAgent(serverId, agentId);
return attempt !== null && (attempt.phase !== "complete" || !attempt.agentObserved);
}
/**
* Whether prompts may be sent to the agent now. The host publishes a created agent before it
* starts the initial prompt, so a creating agent is ready only once its creation completes.
*/
export function isAgentReadyForPrompts(serverId: string, agentId: string): boolean {
return (
selectIsAgentPublished(useSessionStore.getState().sessions[serverId], agentId) &&
!isAgentCreationPending(serverId, agentId)
);
}
export function useIsAgentReadyForPrompts(serverId: string, agentId: string): boolean {
const isPublished = useSessionStore((state) =>
selectIsAgentPublished(state.sessions[serverId], agentId),
);
const creation = useAgentCreation(serverId, agentId);
return isPublished && creation === null;
}
@@ -1,292 +0,0 @@
import { i18n } from "@/i18n/i18next";
import { router } from "expo-router";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
import type { PendingWorkspaceDraftSetup } from "@/stores/workspace-draft-submission-store";
import { remapDraftCwdToWorkspace } from "@/screens/new-workspace-fork-context";
import { captureCreationInput } from "./internal/capture";
import { useDraftStore } from "@/stores/draft-store";
import {
buildDraftWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
} from "@/attachments/workspace-attachments-store";
import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import type { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { MessagePayload } from "@/composer/types";
import type { useAgentInputDraft } from "@/composer/draft/input-draft";
import {
splitComposerAttachmentsForSubmit,
resolveComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import { ensureCheckoutStatus } from "@/git/checkout-status-cache";
import { getHostProjectId, type HostProjectListItem } from "@/projects/host-projects";
import { captureWorkspaceDraftCleanup } from "@/screens/new-workspace/background-handoff";
import { getAttachmentKey } from "@/composer/attachments/workspace-cleanup";
import {
defaultBasePickerItem,
pickerItemToCheckoutRequest,
type PickerItem,
} from "@/screens/new-workspace-picker-item";
import { useSessionStore } from "@/stores/session-store";
import { transferComposerFollowUps } from "@/composer/submission/writer";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceDraftTabSetup,
} from "@/workspace-tabs/model";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import { encodeImages } from "@/utils/encode-images";
import type { CreationAttempt } from "./model";
import { workspaceCreations } from "./runtime";
type ComposerState = NonNullable<ReturnType<typeof useAgentInputDraft>["composerState"]>;
type DraftCleanupInput = Parameters<typeof captureWorkspaceDraftCleanup>[0];
interface SubmitWorkspaceChatInput extends DraftCleanupInput {
serverId: string;
key: string;
returnDraftId?: string;
project: HostProjectListItem | null;
sourceDirectory: string | null;
worktreeSlug: string;
createsWorktree: boolean;
selectedItem: PickerItem | null;
payload: MessagePayload;
forkSetup: PendingWorkspaceDraftSetup | null;
composerState: ComposerState | null;
supportsForgeSearch: boolean;
client: DaemonClient;
queryClient: QueryClient;
persistPreferences: () => Promise<unknown>;
}
interface WorkspaceCreationFailure {
error: string;
selectedItem: PickerItem | null;
}
// The returned draft takes back its form selection and ordinary submit error together.
const workspaceFailures = new Map<string, WorkspaceCreationFailure>();
export function takeWorkspaceCreationFailure(draftKey: string): WorkspaceCreationFailure | null {
const failure = workspaceFailures.get(draftKey) ?? null;
workspaceFailures.delete(draftKey);
return failure;
}
/**
* The composer released the submitted context when it submitted; the draft that takes the
* submission back owns it again. Context added to the draft since then stays as it is.
*/
function restoreSubmittedContext(draftId: string, payload: MessagePayload) {
const scopeKey = buildDraftWorkspaceAttachmentScopeKey(draftId);
const attachments = useWorkspaceAttachmentsStore.getState();
const present = new Set((attachments.attachmentsByScope[scopeKey] ?? []).map(getAttachmentKey));
for (const attachment of payload.attachments.filter(isWorkspaceAttachment)) {
if (!present.has(getAttachmentKey(attachment)))
attachments.addWorkspaceAttachment({ scopeKey, attachment });
}
}
/**
* The workspace exists without its agent, so the input continues as that workspace's agent draft,
* failed exactly as a draft whose own agent creation failed, holding the submitted input, context
* and setup.
*/
function continueAsFailedAgentDraft(input: {
attempt: CreationAttempt;
draftId: string;
payload: MessagePayload;
setup: WorkspaceDraftTabSetup;
sourceDirectory: string;
workspace: CreationSnapshot["workspace"];
error: string;
}) {
const { attempt, draftId } = input;
const workspaceDirectory =
input.workspace?.workspaceDirectory ??
useSessionStore.getState().sessions[attempt.serverId]?.workspaces.get(attempt.workspaceId)
?.workspaceDirectory ??
input.sourceDirectory;
useDraftStore.getState().saveDraftInput({
draftKey: buildDraftStoreKey({ serverId: attempt.serverId, agentId: "", draftId }),
draft: {
text: input.payload.text,
attachments: userAttachmentsOnly(input.payload.attachments),
},
});
restoreSubmittedContext(draftId, input.payload);
const createFlow = useCreateFlowStore.getState();
createFlow.setPending({
draftId,
serverId: attempt.serverId,
workspaceId: attempt.workspaceId,
agentId: null,
clientMessageId: `${draftId}:initial-message`,
text: attempt.text,
timestamp: Date.now(),
});
createFlow.markLifecycle({ draftId, lifecycle: "abandoned", errorMessage: input.error });
const layouts = useWorkspaceLayoutStore.getState();
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: attempt.serverId,
workspaceId: attempt.workspaceId,
});
if (!workspaceKey) return;
// The submitted cwd is relative to the source checkout; the draft runs in the created workspace.
const setup = {
...input.setup,
cwd: remapDraftCwdToWorkspace({
cwd: input.setup.cwd,
sourceDirectory: input.sourceDirectory,
workspaceDirectory,
}),
};
const target = { kind: "draft", draftId, setup } as const;
const agentTabs = layouts
.getWorkspaceTabs(workspaceKey)
.filter((tab) => tab.target.kind === "agent" && tab.target.agentId === attempt.agentId);
const draftTabIds = agentTabs.length
? agentTabs.map((tab) => layouts.replaceTab(workspaceKey, tab.tabId, target))
: [layouts.openTab({ workspaceKey, target, intent: "background" })];
// The draft composer owns the follow-ups until its own agent creation hands them on.
const draftTabId = draftTabIds.find((tabId) => tabId !== null);
if (draftTabId) transferComposerFollowUps(attempt.serverId, attempt.agentId, draftTabId);
}
/** Captures one intent. All preparation continues under the app owner after navigation. */
export function submitWorkspaceChat(input: SubmitWorkspaceChatInput) {
const { serverId, project, sourceDirectory, composerState } = input;
if (!useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.creationLifecycle) {
throw new Error(i18n.t("newWorkspace.creation.updateHost"));
}
if (!project || !sourceDirectory)
throw new Error(i18n.t("newWorkspace.creation.chooseProjectHost"));
if (!composerState?.selectedProvider) throw new Error(i18n.t("newWorkspace.errors.selectModel"));
const projectId = getHostProjectId(project, serverId);
if (!projectId) throw new Error(i18n.t("newWorkspace.creation.projectUnavailable"));
const payload = captureCreationInput(input.payload);
const provider = composerState.selectedProvider;
const cwd = input.forkSetup
? remapDraftCwdToWorkspace({
cwd: input.forkSetup.setup.cwd,
sourceDirectory,
workspaceDirectory: sourceDirectory,
})
: payload.cwd;
const config = captureCreationInput({
provider,
cwd,
modeId: composerState.effectiveModeId || undefined,
model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
featureValues: composerState.featureValues,
});
const wire = splitComposerAttachmentsForSubmit(payload.attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: input.supportsForgeSearch,
}),
});
const selectedItem = captureCreationInput(input.selectedItem);
const clearConsumedDraft = captureWorkspaceDraftCleanup(input);
const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version;
const intent = JSON.stringify([
input.key,
draftVersion,
projectId,
sourceDirectory,
input.createsWorktree,
selectedItem,
config,
payload,
]);
const submitted = workspaceCreations.submit({
serverId,
key: intent,
provider,
config: {
modeId: config.modeId,
model: config.model,
thinkingOptionId: config.thinkingOptionId,
},
projectId,
projectName: project.projectName,
text: payload.text,
onWorkspaceFailed: (attempt, error) => {
workspaceFailures.set(input.draftKey, { error, selectedItem });
if (input.returnDraftId) restoreSubmittedContext(input.returnDraftId, payload);
// The New workspace composer owns the follow-ups until the form is submitted again.
transferComposerFollowUps(serverId, attempt.agentId, input.draftKey);
router.replace(
buildNewWorkspaceRoute({
serverId,
sourceDirectory,
projectId,
draftId: input.returnDraftId,
}) as never,
);
},
onAgentFailed: (attempt, error, workspace) => {
// Release the form first; the draft then takes the submitted input and its context.
clearConsumedDraft();
continueAsFailedAgentDraft({
attempt,
draftId: input.draftId?.trim() || generateDraftId(),
payload,
setup: {
provider,
cwd,
modeId: config.modeId ?? null,
model: config.model ?? null,
thinkingOptionId: config.thinkingOptionId ?? null,
featureValues: config.featureValues ?? {},
},
sourceDirectory,
workspace,
error,
});
},
message: {
kind: "user_message",
text: payload.text,
timestamp: new Date(),
images: wire.images,
attachments: wire.attachments,
},
prepare: async () => {
await input.persistPreferences();
const images = await encodeImages(wire.images);
const status = input.createsWorktree
? await ensureCheckoutStatus({
queryClient: input.queryClient,
client: input.client,
serverId,
cwd: sourceDirectory,
})
: null;
const checkout = status
? pickerItemToCheckoutRequest(selectedItem ?? defaultBasePickerItem(status))
: undefined;
return {
source: input.createsWorktree
? {
kind: "worktree",
cwd: sourceDirectory,
projectId,
worktreeSlug: input.worktreeSlug,
...checkout,
}
: { kind: "directory", path: sourceDirectory, projectId },
agent: { config, initialPrompt: payload.text, images, attachments: wire.attachments },
};
},
onComplete: clearConsumedDraft,
});
// Follow-ups kept by a returned form continue behind this submission's initial prompt.
transferComposerFollowUps(serverId, input.draftKey, submitted.agentId);
return submitted;
}
@@ -1,56 +0,0 @@
import { useTranslation } from "react-i18next";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { WorkspaceTabMenuEntry } from "@/screens/workspace/workspace-tab-menu";
import { useMemo } from "react";
import { PressHighlight } from "@/components/ui/press-highlight";
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useAgentCreation } from "../runtime";
import type { CreationAttempt } from "../model";
export function CreatingWorkspaceRow({
attempt,
selected,
onPress,
}: {
attempt: CreationAttempt;
selected: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<PressHighlight
accessibilityRole="button"
accessibilityLabel={t("newWorkspace.creation.creatingLabel")}
accessibilityState={accessibilityState}
testID={`sidebar-workspace-row-${attempt.serverId}:${attempt.workspaceId}`}
onPress={onPress}
style={styles.pendingRow}
>
<Text style={styles.status}>{t("newWorkspace.creation.creatingWorkspace")}</Text>
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
pendingRow: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[2] },
status: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base },
}));
const RESOURCE_ACTIONS = new Set(["rename", "reload-agent", "copy-resume-command"]);
export function useCreationTabMenuEntries(
serverId: string,
target: WorkspaceTabTarget,
entries: WorkspaceTabMenuEntry[],
): WorkspaceTabMenuEntry[] {
const agentId = target.kind === "agent" ? target.agentId : "";
const creation = useAgentCreation(serverId, agentId);
return useMemo(() => {
if (!creation) return entries;
return entries.map((entry) =>
entry.kind === "item" && RESOURCE_ACTIONS.has(entry.key)
? { ...entry, disabled: true }
: entry,
);
}, [creation, entries]);
}
@@ -1,5 +1,3 @@
import { useWorkspaceCreations } from "@/workspace-creation";
import type { CreationAttempt } from "@/workspace-creation/model";
import { useShallow } from "zustand/shallow";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
@@ -15,21 +13,12 @@ export function deriveEffectiveWorkspaceStatus(input: {
serverId: string;
workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
}
const creation = input.workspaceCreations?.find(
(attempt) =>
attempt.serverId === input.serverId &&
attempt.workspaceId === input.workspace.id &&
attempt.phase !== "complete",
);
if (creation) return { status: "running", enteredAt: creation.message?.timestamp ?? null };
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
serverId: input.serverId,
workspaceId: input.workspace.id,
@@ -66,7 +55,6 @@ function getPendingInitialAgentCreateStartedAt(input: {
}
export function useWorkspaceStatus(serverId: string, workspaceId: string) {
const workspaceCreations = useWorkspaceCreations();
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
return useSessionStore(
useShallow((state) => {
@@ -79,7 +67,6 @@ export function useWorkspaceStatus(serverId: string, workspaceId: string) {
serverId,
workspace,
pendingCreateAttempts,
workspaceCreations,
workspaceAgentActivity: session.workspaceAgentActivity,
}).status;
}),
@@ -1,6 +1,7 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
buildWorkspaceTabSnapshot,
createWorkspaceAgentVisibilitySelector,
@@ -63,6 +64,118 @@ function makeAgent(input: {
const WORKSPACE_ID = "ws-1";
describe("workspace agent visibility", () => {
it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => {
const values = new Map<string, string>();
const storage = {
getItem: async (key: string) => values.get(key) ?? null,
setItem: async (key: string, value: string) => {
values.set(key, value);
},
removeItem: async (key: string) => {
values.delete(key);
},
};
const original = createWorkspaceLayoutStore(undefined, storage);
await original.persist.rehydrate();
const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" });
for (const workspaceId of ["ws-a", "ws-b"]) {
original.getState().openTab({
workspaceKey: `srv:${workspaceId}`,
target: { kind: "agent", agentId: agent.id },
intent: "reveal",
pin: true,
});
}
const restored = createWorkspaceLayoutStore(undefined, storage);
await restored.persist.rehydrate();
const sessionAgents = new Map([[agent.id, agent]]);
const beforeHydration = buildWorkspaceTabSnapshot({
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }),
agentsHydrated: false,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
});
restored.getState().reconcileTabs("srv:ws-b", beforeHydration);
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id]));
for (const workspaceId of ["ws-a", "ws-b"]) {
restored.getState().reconcileTabs(
`srv:${workspaceId}`,
buildWorkspaceTabSnapshot({
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }),
agentsHydrated: true,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
}),
);
}
expect(
restored
.getState()
.getWorkspaceTabs("srv:ws-a")
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.target),
).toEqual([{ kind: "agent", agentId: agent.id }]);
expect(
restored
.getState()
.getWorkspaceTabs("srv:ws-b")
.filter((tab) => tab.target.kind === "agent"),
).toEqual([]);
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined();
});
it("uses directory ownership before historical details when identifying foreign agents", () => {
const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID });
const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" });
const unknown = makeAgent({ id: "unknown", cwd: "/repo" });
const result = deriveWorkspaceAgentVisibility({
workspaceId: WORKSPACE_ID,
sessionAgents: new Map([
[own.id, own],
[foreign.id, foreign],
[unknown.id, unknown],
]),
agentDetails: new Map([
[own.id, { ...own, workspaceId: "ws-2" }],
[foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }],
[unknown.id, { ...unknown, workspaceId: "ws-2" }],
]),
});
expect(result.activeAgentIds).toEqual(new Set([own.id]));
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
});
it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => {
const archived = makeAgent({
id: "archived",
cwd: "/repo",
workspaceId: WORKSPACE_ID,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
});
const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" };
const unknown = { ...archived, id: "unknown", workspaceId: undefined };
const result = deriveWorkspaceAgentVisibility({
workspaceId: WORKSPACE_ID,
sessionAgents: new Map(),
agentDetails: new Map([
[archived.id, archived],
[foreign.id, foreign],
[unknown.id, unknown],
]),
});
expect(result.activeAgentIds).toEqual(new Set());
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
});
it("keeps subagents active while excluding them from auto-open", () => {
const parent = makeAgent({
id: "parent-agent",
@@ -86,6 +199,7 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
expect(result.foreignAgentIds).toEqual(new Set());
});
it("excludes archived subagents from active and auto-open", () => {
@@ -295,6 +409,7 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"]));
});
it("excludes agents without a workspaceId", () => {
@@ -308,12 +423,14 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.foreignAgentIds).toEqual(new Set<string>());
});
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
const agentVisibility = {
activeAgentIds: new Set(["active-agent"]),
autoOpenAgentIds: new Set(["root-agent"]),
foreignAgentIds: new Set(["foreign-agent"]),
};
expect(
@@ -331,6 +448,7 @@ describe("workspace agent visibility", () => {
terminalsHydrated: true,
activeAgentIds: agentVisibility.activeAgentIds,
autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
foreignAgentIds: agentVisibility.foreignAgentIds,
knownTerminalIds: ["terminal-1", "script-terminal"],
standaloneTerminalIds: ["terminal-1"],
hasActivePendingTerminalCreate: false,
@@ -469,6 +587,24 @@ describe("workspace agent visibility", () => {
expect(after.activeAgentIds).toEqual(new Set([child.id]));
expect(after.autoOpenAgentIds).toEqual(new Set([child.id]));
});
it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => {
seedSession(new Map([[parent.id, parent]]));
const select = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
const before = select(useSessionStore.getState());
useSessionStore
.getState()
.setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]]));
const after = select(useSessionStore.getState());
expect(after).not.toBe(before);
expect(after.activeAgentIds).toEqual(before.activeAgentIds);
expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds);
expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id]));
});
});
describe("workspaceAgentVisibilityEqual", () => {
@@ -476,10 +612,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
};
const b = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
@@ -488,10 +626,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set(["b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
@@ -500,10 +640,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["b"]),
foreignAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
@@ -512,10 +654,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
@@ -6,10 +6,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export interface WorkspaceAgentVisibility {
activeAgentIds: Set<string>;
autoOpenAgentIds: Set<string>;
}
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
foreignAgentIds: Set<string>;
}
export function deriveWorkspaceAgentVisibility(input: {
@@ -19,17 +16,23 @@ export function deriveWorkspaceAgentVisibility(input: {
}): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails } = input;
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if ((!sessionAgents && !agentDetails) || !workspaceId) {
if (!workspaceId) {
return {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
};
}
const activeAgentIds = new Set<string>();
const autoOpenAgentIds = new Set<string>();
const foreignAgentIds = new Set<string>();
for (const agent of sessionAgents?.values() ?? []) {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (ownerId !== workspaceId) {
if (ownerId) {
foreignAgentIds.add(agent.id);
}
continue;
}
if (!agent.archivedAt) {
@@ -42,7 +45,16 @@ export function deriveWorkspaceAgentVisibility(input: {
}
}
}
return { activeAgentIds, autoOpenAgentIds };
for (const agent of agentDetails?.values() ?? []) {
if (sessionAgents?.has(agent.id)) {
continue;
}
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (ownerId && ownerId !== workspaceId) {
foreignAgentIds.add(agent.id);
}
}
return { activeAgentIds, autoOpenAgentIds, foreignAgentIds };
}
interface AgentMapsSnapshot {
@@ -99,6 +111,7 @@ export function buildWorkspaceTabSnapshot(input: {
terminalsHydrated: input.terminalsHydrated,
activeAgentIds: input.agentVisibility.activeAgentIds,
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
foreignAgentIds: input.agentVisibility.foreignAgentIds,
knownTerminalIds: input.knownTerminalIds,
standaloneTerminalIds: input.standaloneTerminalIds,
hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate,
@@ -113,7 +126,8 @@ export function workspaceAgentVisibilityEqual(
return (
a === b ||
(setsEqual(a.activeAgentIds, b.activeAgentIds) &&
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds))
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) &&
setsEqual(a.foreignAgentIds, b.foreignAgentIds))
);
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.13.1",
"version": "0.13.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/server": "0.13.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.13.1",
"version": "0.13.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.1",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.13.1",
"version": "0.13.2",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.1",
"version": "0.13.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.13.1",
"version": "0.13.2",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.13.1",
"version": "0.13.2",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -83,8 +83,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.13.1",
"version": "0.13.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.13.1",
"version": "0.13.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.13.1",
"version": "0.13.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.1",
"@getpaseo/highlight": "0.13.1",
"@getpaseo/plugin": "0.13.1",
"@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1,4 +1,3 @@
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { resolveCreateAgentIntent } from "./intent.js";
@@ -24,38 +23,6 @@ describe("resolveCreateAgentIntent", () => {
});
});
it("places a combined creation inside its selected workspace and keeps parentage", async () => {
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: "workspace-new",
workspaceRelativeCwd: "packages/app",
caller: { id: "parent", cwd: "/source", workspaceId: "workspace-source" },
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/new" }),
createWorkspace: async () => {
throw new Error("Unexpected workspace creation");
},
});
expect(intent.cwd).toBe(resolve("/new", "packages/app"));
expect(intent.workspaceId).toBe("workspace-new");
expect(intent.parentAgentId).toBe("parent");
});
it.each(["../outside", "/outside"])(
"rejects an invalid relative placement: %s",
async (workspaceRelativeCwd) => {
await expect(
resolveCreateAgentIntent({
explicitWorkspaceId: "workspace-new",
workspaceRelativeCwd,
caller: null,
resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: "/new" }),
createWorkspace: async () => {
throw new Error("Unexpected workspace creation");
},
}),
).rejects.toThrow("inside the selected workspace");
},
);
it("defaults an agent caller to its workspace without creating one", async () => {
let createCount = 0;
const intent = await resolveCreateAgentIntent({
@@ -1,4 +1,3 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
export interface CreateAgentCaller {
@@ -21,9 +20,6 @@ export interface CreateAgentIntent {
export async function resolveCreateAgentIntent(input: {
explicitWorkspaceId?: string;
// Combined creation remaps a source-relative directory into the selected workspace.
// Ordinary explicit-workspace requests continue to use the workspace's root.
workspaceRelativeCwd?: string;
caller: CreateAgentCaller | null;
labels?: Record<string, string>;
childAgentDefaultLabels?: Record<string, string>;
@@ -51,27 +47,12 @@ export async function resolveCreateAgentIntent(input: {
async function resolvePlacement(input: {
explicitWorkspaceId?: string;
workspaceRelativeCwd?: string;
caller: CreateAgentCaller | null;
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
createWorkspace: () => Promise<CreateAgentPlacement>;
}): Promise<CreateAgentPlacement> {
if (input.explicitWorkspaceId) {
const placement = await input.resolveWorkspace(input.explicitWorkspaceId);
if (input.workspaceRelativeCwd === undefined) return placement;
const cwd = resolve(placement.cwd, input.workspaceRelativeCwd);
const relativeCwd = relative(resolve(placement.cwd), cwd);
if (
isAbsolute(input.workspaceRelativeCwd) ||
relativeCwd === ".." ||
relativeCwd.startsWith(`..${sep}`)
) {
throw new Error("Agent directory must be inside the selected workspace");
}
return { ...placement, cwd };
}
if (input.workspaceRelativeCwd !== undefined) {
throw new Error("A relative agent directory requires an explicit workspace");
return input.resolveWorkspace(input.explicitWorkspaceId);
}
if (input.caller) {
if (!input.caller.workspaceId) {
@@ -811,6 +811,46 @@ describe("ACPAgentSession terminal tools", () => {
truncated: false,
});
});
test("trims large terminal output without rescanning the retained suffix", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'],
cwd: process.cwd(),
outputByteLimit: 1024,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "x".repeat(1024),
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
}, 1000);
test("trims terminal output at a UTF-8 character boundary", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("😀ab")'],
cwd: process.cwd(),
outputByteLimit: 3,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "ab",
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
});
});
describe("mapACPUsage", () => {
@@ -4093,10 +4093,16 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
if (!limit) {
return;
}
while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) {
entry.output = entry.output.slice(1);
entry.truncated = true;
const output = Buffer.from(entry.output, "utf8");
if (output.length <= limit) {
return;
}
let start = output.length - limit;
while (start < output.length && (output[start] & 0xc0) === 0x80) {
start += 1;
}
entry.output = output.subarray(start).toString("utf8");
entry.truncated = true;
}
function readRecord(value: unknown): Record<string, unknown> | null {
@@ -3063,6 +3063,81 @@ describe("PiRpcAgentClient", () => {
]);
});
test.each([
{ outcome: "successful", error: null },
{ outcome: "failed", error: new Error("summarizer failed") },
])("waits for $outcome manual compaction before sending a new prompt", async ({ error }) => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
fakeSession.compactError = error;
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
const { turnId } = await session.startTurn("continue after compaction");
await flushTurnScheduling();
expect(fakeSession.compactRequests).toEqual([{}]);
expect(fakeSession.prompts).toEqual([]);
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.prompts).toEqual([{ message: "continue after compaction", imageCount: 0 }]);
fakeSession.finishTurn();
await events.nextTurnCompletion();
expect(events.turnLifecycleEvents()).toContainEqual({ type: "turn_completed", turnId });
expect(events.eventTypes()).not.toContain("turn_failed");
});
test.each(["interrupt", "close"] as const)(
"does not send a prompt waiting on compaction after %s",
async (action) => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
await session.startTurn("do not send this prompt");
await session[action]();
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.prompts).toEqual([]);
},
);
test("does not send a prompt when compaction finishes during interruption", async () => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const clearQueueFinished = Promise.withResolvers<void>();
fakeSession.clearQueueCompletion = clearQueueFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
const { turnId } = await session.startTurn("do not send this prompt");
const interruption = session.interrupt();
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.controlRequests).toEqual(["clear_queue"]);
expect(fakeSession.prompts).toEqual([]);
clearQueueFinished.resolve();
await interruption;
expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_canceled", turnId }]);
expect(fakeSession.prompts).toEqual([]);
});
test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
@@ -1189,6 +1189,7 @@ export class PiRpcAgentSession implements AgentSession {
private readonly contextUserEntries: PiCapturedEntry[] = [];
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private compactionPromise: Promise<void> | null = null;
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
private outOfBandCompactionStarted = false;
private outOfBandCompactionCompleted = false;
@@ -1296,6 +1297,15 @@ export class PiRpcAgentSession implements AgentSession {
void (async () => {
try {
if (this.compactionPromise) {
// Pi RPC rejects prompts until the manual compaction request settles.
await this.compactionPromise.catch(() => undefined);
const turnCanceled =
this.closed || this.activeTurnId !== turnId || this.interruptingTurn?.turnId === turnId;
if (turnCanceled) {
return;
}
}
const ack = await this.runtimeSession.prompt(payload.text, payload.images);
this.activePromptRequestId = ack.requestId ?? null;
const correlatedResult = ack.requestId
@@ -1804,7 +1814,8 @@ export class PiRpcAgentSession implements AgentSession {
this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false;
try {
await this.runtimeSession.compact(customInstructions);
this.compactionPromise = this.runtimeSession.compact(customInstructions);
await this.compactionPromise;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
@@ -1830,6 +1841,7 @@ export class PiRpcAgentSession implements AgentSession {
},
});
} finally {
this.compactionPromise = null;
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
this.outOfBandCompactionEmit = null;
this.outOfBandCompactionStarted = false;
@@ -108,6 +108,7 @@ export class FakePiSession implements PiRuntimeSession {
steerError: Error | null = null;
readonly controlRequests: string[] = [];
clearQueueError: Error | null = null;
clearQueueCompletion: Promise<void> | null = null;
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
@@ -140,6 +141,7 @@ export class FakePiSession implements PiRuntimeSession {
subagentSubscriptionError: Error | null = null;
setSessionNameError: Error | null = null;
compactError: Error | null = null;
compactCompletion: Promise<void> | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
getSessionStatsError: Error | null = null;
@@ -233,6 +235,7 @@ export class FakePiSession implements PiRuntimeSession {
async clearQueue(): Promise<void> {
this.controlRequests.push("clear_queue");
await this.clearQueueCompletion;
if (this.clearQueueError) {
throw this.clearQueueError;
}
@@ -241,6 +244,7 @@ export class FakePiSession implements PiRuntimeSession {
async compact(customInstructions?: string): Promise<void> {
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
this.emit({ type: "compaction_start", reason: "manual" });
await this.compactCompletion;
if (this.emitCompactEnd) {
this.emit({ type: "compaction_end", reason: "manual" });
}
@@ -0,0 +1,147 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js";
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-"));
});
afterEach(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it.each(["EPERM", "EACCES", "EBUSY"])(
"commits a completed receipt after transient Windows %s replace failures",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
let failures = 3;
let elapsed = 0;
await writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
elapsed += milliseconds;
},
rename: async (source, destination) => {
if (failures-- > 0) {
expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({
state: "pending",
});
expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" });
throw Object.assign(new Error("destination held open"), { code });
}
await fs.rename(source, destination);
},
},
);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
expect(elapsed).toBeGreaterThan(0);
},
);
it.each(["EPERM", "EACCES", "EBUSY"])(
"preserves the previous file and removes the temp file when Windows %s persists",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("destination held open"), { code });
const waits: number[] = [];
let elapsed = 0;
const attempts: number[] = [];
await expect(
writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
elapsed += milliseconds;
},
rename: async () => {
attempts.push(elapsed);
throw error;
},
},
),
).rejects.toBe(error);
expect(elapsed).toBe(1000);
expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]);
expect(Math.max(...waits)).toBe(100);
expect(attempts.every((time) => time < 1000)).toBe(true);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
},
);
it.each([
["linux", "EPERM"],
["darwin", "EACCES"],
["linux", "EBUSY"],
["win32", "ENOENT"],
["win32", "ENOSPC"],
["win32", "EXDEV"],
] as const)("does not retry %s %s failures", async (platform, code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("rename failed"), { code });
let attempts = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform,
now: () => 0,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
attempts++;
throw error;
},
}),
).rejects.toBe(error);
expect(attempts).toBe(1);
expect(waits).toEqual([]);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
});
it("does not retry after a rename consumes the Windows retry budget", async () => {
const file = path.join(directory, "receipt.json");
const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" });
let elapsed = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
elapsed += 1000;
throw error;
},
}),
).rejects.toBe(error);
expect(waits).toEqual([]);
expect(await fs.readdir(directory)).toEqual([]);
});
it("creates parent directories and replaces binary and JSON files through the default runtime", async () => {
const file = path.join(directory, "nested", "file");
const bytes = Buffer.from([0, 255, 128, 1]);
await writeFileAtomic(file, bytes);
expect(await fs.readFile(file)).toEqual(bytes);
await writeJsonFileAtomic(file, { state: "completed" });
expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}');
expect(await fs.readdir(path.dirname(file))).toEqual(["file"]);
});
+54 -3
View File
@@ -1,10 +1,26 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
interface AtomicFileRuntime {
platform: NodeJS.Platform;
rename: (source: string, destination: string) => Promise<void>;
now: () => number;
sleep: (milliseconds: number) => Promise<void>;
}
const runtime: AtomicFileRuntime = {
platform: process.platform,
rename: fs.rename,
now: () => performance.now(),
sleep,
};
export async function writeFileAtomic(
filePath: string,
data: string | NodeJS.ArrayBufferView,
operations: AtomicFileRuntime = runtime,
): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = path.join(
@@ -13,13 +29,48 @@ export async function writeFileAtomic(
);
try {
await fs.writeFile(tempPath, data, "utf8");
await fs.rename(tempPath, filePath);
await replaceFile(tempPath, filePath, operations);
} catch (error) {
await fs.rm(tempPath, { force: true });
throw error;
}
}
export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
await writeFileAtomic(filePath, JSON.stringify(value, null, 2));
async function replaceFile(
source: string,
destination: string,
operations: AtomicFileRuntime,
): Promise<void> {
// Windows readers/scanners can briefly deny replacement. Yield with backoff,
// but keep interactive persistence failures bounded to a one-second retry budget.
const deadline = operations.now() + 1000;
let delay = 10;
for (;;) {
try {
await operations.rename(source, destination);
return;
} catch (error) {
if (
operations.platform !== "win32" ||
!(error instanceof Error) ||
!("code" in error) ||
!["EPERM", "EACCES", "EBUSY"].includes(String(error.code))
) {
throw error;
}
const remaining = deadline - operations.now();
if (remaining <= 0) throw error;
await operations.sleep(Math.min(delay, remaining));
if (operations.now() >= deadline) throw error;
delay = Math.min(delay * 2, 100);
}
}
}
export async function writeJsonFileAtomic(
filePath: string,
value: unknown,
operations: AtomicFileRuntime = runtime,
): Promise<void> {
await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations);
}

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