Compare commits

..
Author SHA1 Message Date
Mohamed Boudra c708c263c8 fix(app): retain checkout selection after workspace failure
Return the submitted checkout through the existing draft failure handoff. Keep client-owned receipt ordering and retries out of app presentation, and retain direct coverage of newer draft cleanup while removing the unused background-agent wrapper.
2026-10-26 12:02:52 +01:00
Mohamed Boudra 0b55d63ab1 fix(app): preserve workspace creation state through adoption
Publish the last draft edit before capturing cleanup ownership and keep published workspaces working while their initial agent creation is pending. Update browser journeys for the normal editable agent panel and make the placement assertion portable across platforms.
2026-10-26 10:29:24 +01:00
Mohamed Boudra 3a3ccd5385 feat(app): open workspace chats immediately
Reserve the final workspace, agent, and initial message identities before preparation so the normal agent panel can own the conversation throughout creation. Keep follow-ups queued until the first prompt starts and hand failures back to the existing workspace form or failed agent draft.
2026-10-26 09:50:00 +01:00
107 changed files with 3532 additions and 1694 deletions
-28
View File
@@ -169,34 +169,6 @@ jobs:
NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }}
run: | run: |
set -euo pipefail 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=(-- --publish never --mac --${{ matrix.electron_arch }})
build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
build_args+=("-c.publish.channel=$RELEASE_CHANNEL") build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
-9
View File
@@ -1,14 +1,5 @@
# Changelog # 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 ## 0.13.1 - 2026-10-26
### Fixed ### Fixed
-9
View File
@@ -1,14 +1,5 @@
# 更新日志 # 更新日志
## 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 ## 0.13.1 - 2026-10-26
### 修复 ### 修复
+44 -5
View File
@@ -340,11 +340,50 @@ Example: adding a new enum value
### Creation ownership ### Creation ownership
Creation is owned by the daemon across socket lifetimes. A workspace request can include Creation execution is owned by the daemon across socket lifetimes. A workspace request
its initial agent and prompt. Workspace readiness is published before provider startup; can include its initial agent and prompt. The app's `workspace-creation/` module owns
the app keeps its existing disk-ready navigation and observes the remaining creation. chat submission presentation outside React: it reserves final IDs and a message identity,
Callbacks do not advance the workflow. Resource reservations in acknowledgement are then opens the agent target before asynchronous preparation or a daemon response.
identities, not ready workspace records. 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.
The creation journal (`server/creation/`) owns identity and execution for both legacy and The creation journal (`server/creation/`) owns identity and execution for both legacy and
modern creation RPCs. Requesting progress never selects a different journal. Existing modern creation RPCs. Requesting progress never selects a different journal. Existing
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

@@ -1,7 +0,0 @@
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,7 +0,0 @@
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,7 +0,0 @@
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,7 +0,0 @@
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-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE= sha256-SKL7TMIuuV3imTIaQc66UDf3n6V3FB0nGSBucJ6rlA0=
+29 -28
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.13.2", "version": "0.13.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.13.2", "version": "0.13.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
@@ -35695,7 +35695,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -35715,6 +35715,7 @@
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2", "@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1", "@mattermost/react-native-paste-input": "2.0.1",
"@noble/hashes": "^2.4.0",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "0.3.2", "@react-native-masked-view/masked-view": "0.3.2",
"@react-native/normalize-colors": "0.86.3", "@react-native/normalize-colors": "0.86.3",
@@ -37961,12 +37962,12 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.2", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"@getpaseo/server": "0.13.2", "@getpaseo/server": "0.13.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -38213,10 +38214,10 @@
}, },
"packages/client": { "packages/client": {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.2", "@getpaseo/relay": "0.13.1",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -38228,7 +38229,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.13.2", "version": "0.13.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@getpaseo/cli": "*", "@getpaseo/cli": "*",
@@ -38472,7 +38473,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.13.2", "version": "0.13.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
@@ -42091,7 +42092,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
@@ -42120,15 +42121,15 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^1.4.0", "@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0", "i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.13.2", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.2.3", "react": "19.2.3",
@@ -42138,8 +42139,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.13.2", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"react": ">=19.1.0 <20", "react": ">=19.1.0 <20",
"react-native": ">=0.81.5", "react-native": ">=0.81.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -42161,7 +42162,7 @@
}, },
"packages/protocol": { "packages/protocol": {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"semver": "^7.8.5", "semver": "^7.8.5",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -42188,7 +42189,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"@noble/ciphers": "^2.4.0", "@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0", "@noble/curves": "^2.4.0",
@@ -42409,16 +42410,16 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.2", "@getpaseo/client": "0.13.1",
"@getpaseo/highlight": "0.13.2", "@getpaseo/highlight": "0.13.1",
"@getpaseo/plugin": "0.13.2", "@getpaseo/plugin": "0.13.1",
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.2", "@getpaseo/relay": "0.13.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
@@ -43339,7 +43340,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1", "@cloudflare/workers-types": "^4.20260317.1",
@@ -44949,7 +44950,7 @@
}, },
"plugins": { "plugins": {
"name": "@getpaseo/builtin-plugins", "name": "@getpaseo/builtin-plugins",
"version": "0.13.2", "version": "0.13.1",
"dependencies": { "dependencies": {
"@getpaseo/plugin": "*", "@getpaseo/plugin": "*",
"zod": "^4.4.3" "zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.13.2", "version": "0.13.1",
"private": true, "private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents", "description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [ "keywords": [
@@ -13,9 +13,7 @@ import {
closeImageLightbox, closeImageLightbox,
pressInterruptShortcut, pressInterruptShortcut,
expectComposerDraft, expectComposerDraft,
expectComposerDisabled,
expectComposerEditable, expectComposerEditable,
expectAttachButtonDisabled,
fillComposerDraft, fillComposerDraft,
dropFileOnComposer, dropFileOnComposer,
sendDraftToQueue, sendDraftToQueue,
@@ -268,7 +266,9 @@ test.describe("Composer attachments", () => {
} }
}); });
test("composer is locked while new workspace agent is being created", async ({ page }) => { test("composer and attachments stay available while new workspace agent is being created", async ({
page,
}) => {
test.setTimeout(120_000); test.setTimeout(120_000);
const serverId = getServerId(); const serverId = getServerId();
@@ -298,8 +298,9 @@ test.describe("Composer attachments", () => {
await agentCreatedDelay.waitForCreateRequest(); await agentCreatedDelay.waitForCreateRequest();
await agentCreatedDelay.waitForDelayedCreatedStatus(); await agentCreatedDelay.waitForDelayedCreatedStatus();
await expectComposerDisabled(page); await expectComposerEditable(page);
await expectAttachButtonDisabled(page); await attachImageFromMenu(page, TEST_IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
agentCreatedDelay.release(); agentCreatedDelay.release();
@@ -1,4 +1,12 @@
import { expectPickerSelected } from "../support/helpers/new-workspace";
import {
hideCreationLifecycle,
holdWorkspaceProvisioning,
reportUnknownPromptOutcome,
} from "../support/helpers/creation";
import type { createCreationScenario } 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 { expect } from "../support/fixtures";
import { test } from "../support/creation-fixtures"; import { test } from "../support/creation-fixtures";
@@ -105,26 +113,38 @@ test("new workspace navigation and optimistic prompt precede agent initializatio
await creation.expectAgentCount(1); await creation.expectAgentCount(1);
}); });
for (const scenario of [ for (const entry of [
{ suffix: "", prepare: async () => {} },
{ {
suffix: " after remount", name: "an agent draft",
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) => open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.evictAndReturnToDraft(), 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(),
}, },
]) { ]) {
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({ test(`a failed agent start in ${entry.name} keeps the draft, its error and its retry`, async ({
creation, creation,
startup, startup,
}) => { page,
await creation.openWorkspaceForm("local"); }, testInfo) => {
await creation.submitPrompt("Retry this workspace and agent together.", "Create"); const prompt = "Retry this agent after its startup fails.";
await creation.expectAgentStillStarting(); await entry.open(creation);
await creation.submitPrompt(prompt, entry.button);
await startup.fail(); await startup.fail();
await creation.expectStartupFailure(); await creation.expectFailedAgentDraft(prompt);
await creation.expectOneCreatedWorkspace(); await entry.expectWorkspace(creation);
await scenario.prepare(creation); await page.screenshot({ path: testInfo.outputPath("failed-agent-draft.png") });
await creation.submitPrompt("Retry this workspace and agent together."); await creation.resendFailedDraft();
await creation.expectPromptVisible(prompt);
await entry.expectWorkspace(creation);
await creation.expectAgentCount(1); await creation.expectAgentCount(1);
}); });
} }
@@ -171,3 +191,356 @@ for (const entry of [
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0); 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 creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({ test("current app requires a host update for chat workspaces and preserves existing-workspace agent creation", async ({
page, page,
project, project,
client, client,
@@ -104,49 +104,34 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const prompt = "Create this workspace: emit 1 coalesced agent stream updates"; const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, prompt); await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, "Create"); await pressSubmitBeforeTheNextRender(page, "Create");
await gate.waitForRequest();
await expect(page).toHaveURL(/\/workspace\//);
await expect( await expect(
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }), page
.getByText("Update this host to create a workspace with an agent.", { exact: true })
.first(),
).toBeVisible(); ).toBeVisible();
await expect(page).toHaveURL(/\/new\?/);
expect((await client.fetchAgents()).entries).toHaveLength(0); expect((await client.fetchAgents()).entries).toHaveLength(0);
const workspaces = (await client.fetchWorkspaces()).entries.filter( expect(
(workspace) => (await client.fetchWorkspaces()).entries.filter(
workspace.projectId === project.projectId && workspace.id !== project.workspaceId, (workspace) => workspace.projectId === project.projectId,
); ),
expect(workspaces).toHaveLength(1); ).toHaveLength(1);
gate.release(); await page.goto(buildHostWorkspaceRoute(daemon.serverId, project.workspaceId));
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); await createAgentTabFromMenu(page);
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates"; const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt); await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message"); await pressSubmitBeforeTheNextRender(page, "Send message");
await gate.waitForRequest();
gate.release();
await gate.waitForCreated(); await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2); await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const second = (await client.fetchAgents()).entries.find( const agent = (await client.fetchAgents()).entries[0]!.agent;
({ agent }) => agent.id !== first.id, expect(agent.workspaceId).toBe(project.workspaceId);
)!.agent; await client.waitForFinish(agent.id, 20_000);
expect(second.workspaceId).toBe(first.workspaceId); await expectPromptOnce(client, agent.id, secondPrompt);
await client.waitForFinish(second.id, 20_000);
await expectPromptOnce(client, second.id, secondPrompt);
await expect( await expect(
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }), page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
).toHaveText(secondPrompt); ).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) { async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
@@ -1,7 +1,8 @@
import { observeCreationRequests } from "../support/helpers/creation";
import { import {
composerLocator, composerLocator,
enterComposerFullscreen, enterComposerFullscreen,
exitComposerFullscreen, fillComposerDraft,
resizeComposerViewport, resizeComposerViewport,
} from "../support/helpers/composer"; } from "../support/helpers/composer";
import { expect, test } from "../support/fixtures"; import { expect, test } from "../support/fixtures";
@@ -95,6 +96,7 @@ test.describe("New workspace composer draft", () => {
test("does not restore a submitted draft after deferred publication", async ({ page }) => { test("does not restore a submitted draft after deferred publication", async ({ page }) => {
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" }); const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
const requests = await observeCreationRequests(page);
try { try {
await gotoAppShell(page); await gotoAppShell(page);
@@ -134,6 +136,9 @@ test.describe("New workspace composer draft", () => {
}, DRAFT); }, DRAFT);
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 }); 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 openGlobalNewWorkspaceComposer(page);
await expectNewWorkspaceDraft(page, ""); await expectNewWorkspaceDraft(page, "");
} finally { } finally {
@@ -142,10 +147,11 @@ test.describe("New workspace composer draft", () => {
}); });
}); });
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({ test("fullscreen creation opens the inline agent composer and keeps follow-up editing available", async ({
page, page,
}) => { }) => {
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" }); const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
const followUpDraft = `${DRAFT}\nKeep this follow-up draft.`;
const delay = await delayBrowserWorkspaceCreatedResponse(page); const delay = await delayBrowserWorkspaceCreatedResponse(page);
try { try {
await gotoAppShell(page); await gotoAppShell(page);
@@ -157,25 +163,23 @@ test("fullscreen creation returns inline and keeps submission locked when reopen
await page.getByRole("button", { name: "Create", exact: true }).click(); await page.getByRole("button", { name: "Create", exact: true }).click();
await delay.waitForCreateRequest(); await delay.waitForCreateRequest();
await test.step("submission returns inline with the original draft and spinner", async () => { await test.step("submission opens the normal agent panel inline", async () => {
await expect(page).toHaveURL(/\/workspace\//);
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0); await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
await expect(composerLocator(page)).toHaveValue(DRAFT); await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(DRAFT);
await expect(composerLocator(page)).not.toBeEditable(); await expect(composerLocator(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled(); await expect(composerLocator(page)).toBeEditable();
await expect(page.getByRole("progressbar")).toBeVisible();
}); });
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => { await test.step("fullscreen can reopen to edit a follow-up while creation is pending", async () => {
await fillComposerDraft(page, followUpDraft);
await enterComposerFullscreen(page); await enterComposerFullscreen(page);
await expect(page.getByTestId("composer-fullscreen")).toBeVisible(); await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
await expect(composerLocator(page)).toHaveValue(DRAFT); await expect(composerLocator(page)).toHaveValue(followUpDraft);
await expect(composerLocator(page)).not.toBeEditable(); await expect(composerLocator(page)).toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
await exitComposerFullscreen(page);
}); });
delay.release(); delay.release();
await page.waitForURL((url) => url.pathname.includes("/workspace/")); await page.waitForURL((url) => url.pathname.includes("/workspace/"));
await expect(composerLocator(page)).toHaveValue(""); await expect(composerLocator(page)).toHaveValue(followUpDraft);
await expect(composerLocator(page)).toBeEditable(); await expect(composerLocator(page)).toBeEditable();
expect(delay.agentRequests).toHaveLength(1); expect(delay.agentRequests).toHaveLength(1);
} finally { } finally {
@@ -192,11 +192,16 @@ test.describe("New Workspace dictation submit", () => {
await harness.waitForCreateRequest(); await harness.waitForCreateRequest();
const composer = page.getByRole("textbox", { name: "Message agent..." }); const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toHaveValue(TRANSCRIPT); await expect(page).toHaveURL(/\/workspace\//);
await expect(composer).not.toBeEditable(); await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(
TRANSCRIPT,
);
await expect(composer).toHaveValue("");
await expect(composer).toBeEditable();
harness.failWorkspaceCreation(); harness.failWorkspaceCreation();
await expect(page).toHaveURL(/\/new\?/);
await expect(composer).toBeEditable(); await expect(composer).toBeEditable();
await expect(composer).toHaveValue(TRANSCRIPT); await expect(composer).toHaveValue(TRANSCRIPT);
await expect(page.getByText(CREATE_FAILURE).first()).toBeVisible(); await expect(page.getByText(CREATE_FAILURE).first()).toBeVisible();
@@ -504,7 +504,7 @@ test.describe("New workspace flow", () => {
} }
}); });
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => { test("opens the final agent tab before agent creation resolves", async ({ page }) => {
const serverId = getServerId(); const serverId = getServerId();
const tempRepo = await createTempGitRepo("new-workspace-optimistic-"); const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
@@ -568,10 +568,10 @@ test.describe("New workspace flow", () => {
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 }); await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]'); const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 }); await expect(draftTabs).toHaveCount(0);
await expect( await expect(
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'), activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
).toHaveCount(0); ).toHaveCount(1, { timeout: 30_000 });
agentCreatedDelay.release(); agentCreatedDelay.release();
await expect( await expect(
@@ -11,10 +11,7 @@ import {
expectWorkspaceTabVisible, expectWorkspaceTabVisible,
openWorkspaceWithAgents, openWorkspaceWithAgents,
} from "../support/helpers/archive-tab"; } from "../support/helpers/archive-tab";
import { import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher"; import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer"; import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -108,83 +105,6 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
test.describe("Workspace navigation regression", () => { test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 }); 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 ({ test("keeps an offline notification on the agent route until the host connects", async ({
page, page,
}) => { }) => {
+103 -27
View File
@@ -11,27 +11,24 @@ import {
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{ interface StartupGate {
startup: { release(): Promise<void>; fail(): Promise<void> }; release(): Promise<void>;
promptRejection: void; fail(): Promise<void>;
creation: Awaited<ReturnType<typeof createCreationScenario>>; dispose(): Promise<void>;
promptRetry: ReturnType<typeof createPromptRetryScenario>; }
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>; /** Holds every later workspace agent's startup until it is released or failed once. */
}>({ async function installStartupGate(): Promise<StartupGate> {
startup: async ({ page }, provide) => { const client = await connectNewWorkspaceDaemonClient();
void page; const plugin = await copyPluginExample("lifecycle-logger");
const client = await connectNewWorkspaceDaemonClient(); const gate = path.join(plugin.directory, "startup.txt");
const plugin = await copyPluginExample("lifecycle-logger"); const failure = path.join(plugin.directory, "startup-failure.txt");
const gate = path.join(plugin.directory, "startup.txt"); const previous = await client.getDaemonConfig();
const failure = path.join(plugin.directory, "startup-failure.txt"); await writeFile(gate, "hold");
const previous = await client.getDaemonConfig(); await writeFile(failure, "fail");
try { await writeFile(
await writeFile(gate, "hold"); path.join(plugin.directory, "index.server.ts"),
await writeFile(failure, "fail"); `
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
import { readFile, rm } from "node:fs/promises"; import { readFile, rm } from "node:fs/promises";
export default function contribute(server) { export default function contribute(server) {
return server.before("agent.session_open", async ({ request }) => { return server.before("agent.session_open", async ({ request }) => {
@@ -50,17 +47,88 @@ export default function contribute(server) {
return request; 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.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory); await client.installDirectoryPlugin(plugin.directory);
await provide({ await provide({ arm: () => writeFile(failure, "fail") });
release: () => rm(gate, { force: true }),
// Replacing the command must not expose a truncated, empty file to the hook.
fail: () => rename(failure, gate),
});
} finally { } finally {
await rm(gate, { force: true });
await client.removePlugin("lifecycle-logger"); await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup(); await plugin.cleanup();
@@ -114,6 +182,14 @@ export default function contribute(server) {
gate.restore(); 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) => { agentRetries: async ({ page }, provide) => {
await provide(await retryNextAgentCreation(page)); await provide(await retryNextAgentCreation(page));
}, },
+334 -13
View File
@@ -1,12 +1,17 @@
import { awaitAssistantMessage } from "./agent-stream";
import { forkMostRecentAssistantTurnToNewWorkspace } from "./assistant-fork";
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages"; import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port"; import { daemonWsRoutePattern } from "./daemon-port";
import { gotoAppShell } from "./app"; import { gotoAppShell } from "./app";
import { gotoWorkspace } from "./launcher"; import { gotoWorkspace } from "./launcher";
import { fillComposerDraft } from "./composer"; import { attachComposerTestImage, fillComposerDraft } from "./composer";
import { createAgentTabFromMenu } from "./workspace-tabs"; import { createAgentTabFromMenu } from "./workspace-tabs";
import { import {
connectNewWorkspaceDaemonClient,
openNewWorkspaceComposer, openNewWorkspaceComposer,
openStartingRefPicker,
startingRefRow,
selectWorkspaceIsolation, selectWorkspaceIsolation,
loadSessionMessageReaders, loadSessionMessageReaders,
} from "./new-workspace"; } from "./new-workspace";
@@ -44,6 +49,43 @@ export async function captureWorkspaceAgentRequest(page: Page, options: { block:
return { waitForRequest: () => request }; 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> { export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<void> {
const create = page.getByRole("button", { name, exact: true }); const create = page.getByRole("button", { name, exact: true });
await expect(create).toBeEnabled(); await expect(create).toBeEnabled();
@@ -59,9 +101,11 @@ export async function pressSubmitBeforeTheNextRender(page: Page, name: string):
export async function observeCreationRequests(page: Page) { export async function observeCreationRequests(page: Page) {
const frames = await loadSessionMessageReaders(); const frames = await loadSessionMessageReaders();
const pending = new Set<string>(); const pending = new Set<string>();
let workspaceRequests = 0;
page.on("websocket", (socket) => { page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => { socket.on("framesent", ({ payload }) => {
const request = frames.client(payload); const request = frames.client(payload);
if (request?.type === "workspace.create.request") workspaceRequests++;
if ( if (
request?.type === "workspace.create.request" || request?.type === "workspace.create.request" ||
request?.type === "agent.create.request" request?.type === "agent.create.request"
@@ -84,6 +128,7 @@ export async function observeCreationRequests(page: Page) {
}); });
}); });
return { return {
workspaceRequests: () => workspaceRequests,
async settled() { async settled() {
await expect.poll(() => pending.size).toBe(0); await expect.poll(() => pending.size).toBe(0);
}, },
@@ -138,18 +183,101 @@ 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) { export async function createCreationScenario(page: Page) {
const requests = await observeCreationRequests(page); const requests = await observeCreationRequests(page);
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" }); const project = await seedWorkspace({
repoPrefix: "creation-idempotency-",
repo: {
files: [{ path: "nested/context.txt", content: "Nested agent working directory" }],
branches: ["recovery-base"],
},
});
let workspaceId = project.workspaceId; let workspaceId = project.workspaceId;
const evictionWorkspaceIds = new Set<string>();
const sourceClients: Array<{ close(): Promise<void> }> = [];
return { return {
cleanup: project.cleanup, 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);
},
async openWorkspaceForm(isolation: "local" | "worktree") { async openWorkspaceForm(isolation: "local" | "worktree") {
await gotoAppShell(page); await gotoAppShell(page);
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project); await openNewWorkspaceComposer(page, project);
await selectWorkspaceIsolation(page, isolation); await selectWorkspaceIsolation(page, isolation);
}, },
async selectWorktreeBranch(name: string) {
await openStartingRefPicker(page);
await startingRefRow(page, `${name}, local branch`).click();
},
async openAgentDraft() { async openAgentDraft() {
await gotoWorkspace(page, project.workspaceId); await gotoWorkspace(page, project.workspaceId);
await createAgentTabFromMenu(page); await createAgentTabFromMenu(page);
@@ -165,6 +293,74 @@ export async function createCreationScenario(page: Page) {
if (prompt) await fillComposerDraft(page, prompt); if (prompt) await fillComposerDraft(page, prompt);
await pressSubmitBeforeTheNextRender(page, button); 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) { async expectPromptVisible(prompt?: string) {
// Inactive agent tabs retain their timeline DOM beside the visible draft. // Inactive agent tabs retain their timeline DOM beside the visible draft.
const rows = page.getByTestId("user-message").filter({ visible: true }); const rows = page.getByTestId("user-message").filter({ visible: true });
@@ -175,7 +371,7 @@ export async function createCreationScenario(page: Page) {
await expect(page.getByTestId("user-message").first()).toBeVisible(); await expect(page.getByTestId("user-message").first()).toBeVisible();
await expect( await expect(
page page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') .locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
.filter({ visible: true }), .filter({ visible: true }),
).toBeVisible(); ).toBeVisible();
}, },
@@ -224,15 +420,57 @@ export async function createCreationScenario(page: Page) {
.first(), .first(),
).toBeVisible(); ).toBeVisible();
}, },
async expectStartupFailure() { async expectFailedAgentDraft(prompt: string) {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible(); 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 expectOneCreatedWorkspace() { async expectOneCreatedWorkspace() {
await expect(page).toHaveURL(/\/workspace\//); await expect(page).toHaveURL(/\/workspace\//);
await requests.settled(); await requests.settled();
const workspaces = (await project.client.fetchWorkspaces()).entries.filter( const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
(workspace) => (workspace) =>
workspace.projectId === project.projectId && workspace.id !== project.workspaceId, workspace.projectId === project.projectId &&
workspace.id !== project.workspaceId &&
!evictionWorkspaceIds.has(workspace.id),
); );
expect(workspaces).toHaveLength(1); expect(workspaces).toHaveLength(1);
workspaceId = workspaces[0]!.id; workspaceId = workspaces[0]!.id;
@@ -265,8 +503,11 @@ export async function createCreationScenario(page: Page) {
.toBe(title); .toBe(title);
}, },
async evictAndReturnToDraft() { async evictAndReturnToDraft() {
workspaceId = new URL(page.url()).pathname.split("/workspace/")[1]!;
const draft = page const draft = page
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]') .locator(
'[data-testid^="workspace-tab-draft_"][aria-selected="true"], [data-testid^="workspace-tab-agent_"][aria-selected="true"]',
)
.filter({ visible: true }) .filter({ visible: true })
.first(); .first();
const draftTestId = await draft.getAttribute("data-testid"); const draftTestId = await draft.getAttribute("data-testid");
@@ -276,6 +517,7 @@ export async function createCreationScenario(page: Page) {
source: { kind: "directory", path: project.repoPath }, source: { kind: "directory", path: project.repoPath },
}); });
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace"); if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
evictionWorkspaceIds.add(result.workspace.id);
await switchWorkspaceViaSidebar({ await switchWorkspaceViaSidebar({
page, page,
serverId: getServerId(), serverId: getServerId(),
@@ -296,15 +538,18 @@ export async function createCreationScenario(page: Page) {
export function createPromptRetryScenario( export function createPromptRetryScenario(
page: Page, page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>, 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; let firstMessage: ReturnType<typeof gate.getClientRequests>[number] | undefined;
return { return {
holdAcknowledgement() { holdAcknowledgement() {
gate.holdNextServerMessage("agent.create.response"); gate.holdNextServerMessage(responseType);
}, },
async waitForDeliveredPrompt() { async waitForDeliveredPrompt() {
await gate.waitForHeldServerMessage("agent.create.response"); await gate.waitForHeldServerMessage(responseType);
firstMessage = gate.getClientRequests("agent.create.request").at(-1); firstMessage = gate.getClientRequests(requestType).at(-1);
}, },
async disconnectAndReconnect() { async disconnectAndReconnect() {
const fetches = gate.getClientRequestCount("fetch_agents_request"); const fetches = gate.getClientRequestCount("fetch_agents_request");
@@ -315,13 +560,89 @@ export function createPromptRetryScenario(
.toBeGreaterThan(fetches); .toBeGreaterThan(fetches);
}, },
async expectSameAgentAndMessage() { async expectSameAgentAndMessage() {
expect(gate.getClientRequestCount("agent.create.request")).toBe(1); expect(gate.getClientRequestCount(requestType)).toBe(1);
await expect await expect
.poll(() => gate.getClientRequestCount("creation.subscribe.request")) .poll(() => gate.getClientRequestCount("creation.subscribe.request"))
.toBeGreaterThan(0); .toBeGreaterThan(0);
expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage); expect(gate.getClientRequests(requestType).at(-1)).toEqual(firstMessage);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0); expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0);
await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1); 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,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick<
| "createPaseoWorktree" | "createPaseoWorktree"
| "createWorkspace" | "createWorkspace"
| "fetchAgents" | "fetchAgents"
| "fetchAgentTimeline"
| "fetchWorkspaces" | "fetchWorkspaces"
| "getPaseoWorktreeList" | "getPaseoWorktreeList"
| "getDaemonConfig" | "getDaemonConfig"
@@ -1,47 +1,12 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { z } from "zod"; import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage"; import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
import { import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
type WorkspaceTab, type WorkspaceTab,
} from "../../../src/workspace-tabs/model"; } from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id"; 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. */ /** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> { export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state")); const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.13.2", "version": "0.13.1",
"private": true, "private": true,
"main": "index.ts", "main": "index.ts",
"scripts": { "scripts": {
@@ -61,6 +61,7 @@
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@marijn/find-cluster-break": "^1.0.2", "@marijn/find-cluster-break": "^1.0.2",
"@mattermost/react-native-paste-input": "2.0.1", "@mattermost/react-native-paste-input": "2.0.1",
"@noble/hashes": "^2.4.0",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "0.3.2", "@react-native-masked-view/masked-view": "0.3.2",
"@react-native/normalize-colors": "0.86.3", "@react-native/normalize-colors": "0.86.3",
+6 -2
View File
@@ -1,3 +1,4 @@
import { useAgentCreation } from "@/workspace-creation";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { useProviderIcon } from "@/components/provider-icons"; import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry"; import type { PanelDescriptor } from "@/panels/panel-registry";
@@ -33,6 +34,7 @@ export function useAgentDescriptor(
target: { kind: "agent"; agentId: string }, target: { kind: "agent"; agentId: string },
context: { serverId: string }, context: { serverId: string },
): PanelDescriptor { ): PanelDescriptor {
const creation = useAgentCreation(context.serverId, target.agentId);
const descriptorState = useSessionStore( const descriptorState = useSessionStore(
useShallow((state) => { useShallow((state) => {
const session = state.sessions[context.serverId]; const session = state.sessions[context.serverId];
@@ -49,8 +51,10 @@ export function useAgentDescriptor(
}; };
}), }),
); );
const icon = useProviderIcon(descriptorState.provider, context.serverId); const provider = creation ? creation.provider : descriptorState.provider;
return buildAgentDescriptor({ ...descriptorState, icon }); const title = creation ? creation.text : descriptorState.title;
const icon = useProviderIcon(provider, context.serverId);
return buildAgentDescriptor({ ...descriptorState, provider, title, icon });
} }
export function buildAgentDescriptor(input: { export function buildAgentDescriptor(input: {
@@ -30,7 +30,7 @@ import {
type ActiveWorkspaceSelection, type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityIconBox, densityIconProps } from "@/styles/density"; import { density, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -463,12 +463,12 @@ const openInNewWindowLeadingIcon = (
<ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} /> <ThemedExternalLink size={14} uniProps={foregroundMutedColorMapping} />
); );
const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
return ( return (
<ThemedMoreVertical uniProps={hovered ? kebabTriggerIconHoveredProps : kebabTriggerIconProps} /> <ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
); );
} }
@@ -2705,12 +2705,8 @@ const styles = StyleSheet.create((theme) => ({
marginRight: -6, marginRight: -6,
}, },
projectKebabButton: { projectKebabButton: {
// Larger on compact so the kebab is an easy tap target. The negative margins keep its width: 24,
// dots on the same rail as the 24px desktop box and the row at its 44px height. height: 24,
width: { xs: 32, md: 24 },
height: { xs: 32, md: 24 },
marginRight: { xs: -4, md: 0 },
marginVertical: { xs: -4, md: 0 },
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -17,7 +17,6 @@ import { isWeb } from "@/constants/platform";
import { getForgePresentation, normalizeForge } from "@/git/forge"; import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { import {
@@ -71,11 +70,13 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />; const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
const unpinLeadingIcon = <ThemedPinOff 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 }) { function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
return <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />; return (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
);
} }
export interface SidebarWorkspaceMenuProps { export interface SidebarWorkspaceMenuProps {
@@ -438,18 +439,12 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
trigger: { trigger: {
// Larger on compact so the kebab is an easy tap target. padding: 2,
width: { xs: 32, md: 18 },
height: { xs: 32, md: 18 },
alignItems: "center",
justifyContent: "center",
borderRadius: 4, borderRadius: 4,
marginLeft: 2, 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 // 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. // pull the painted dots through that unused view-box space onto the trailing-content rail.
marginRight: { xs: -14, md: -7 }, marginRight: -7,
}, },
triggerHovered: { triggerHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -1,3 +1,5 @@
import { useWorkspaceCreation } from "@/workspace-creation";
import { CreatingWorkspaceRow } from "@/workspace-creation/ui";
import { memo, useCallback, useMemo, useState, type Ref } from "react"; import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, Text, type GestureResponderEvent } from "react-native"; import { View, Text, type GestureResponderEvent } from "react-native";
@@ -58,7 +60,16 @@ interface SidebarWorkspaceRowProps {
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
} }
export function SidebarWorkspaceRow({ 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({
workspace, workspace,
selected, selected,
shortcutNumber, shortcutNumber,
+82
View File
@@ -296,6 +296,7 @@ describe("cancelComposerAgent", () => {
function baseInput(): { function baseInput(): {
client: ComposerCancelClient & { canceledIds: string[] }; client: ComposerCancelClient & { canceledIds: string[] };
agentId: string; agentId: string;
isAgentPublished: boolean;
isAgentRunning: boolean; isAgentRunning: boolean;
isCancellingAgent: boolean; isCancellingAgent: boolean;
isConnected: boolean; isConnected: boolean;
@@ -309,6 +310,7 @@ describe("cancelComposerAgent", () => {
}, },
}, },
agentId: "agent", agentId: "agent",
isAgentPublished: true,
isAgentRunning: true, isAgentRunning: true,
isCancellingAgent: false, isCancellingAgent: false,
isConnected: true, isConnected: true,
@@ -340,6 +342,13 @@ describe("cancelComposerAgent", () => {
expect(input.client.canceledIds).toEqual([]); 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", () => { it("does nothing when the agent is already being canceled", () => {
const input = baseInput(); const input = baseInput();
const result = cancelComposerAgent({ ...input, isCancellingAgent: true }); const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
@@ -419,6 +428,29 @@ describe("pickAndPersistImages", () => {
}); });
describe("dispatchComposerAgentMessage", () => { 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 () => { it("forwards the configured active-turn intent without provider capability checks", async () => {
const client = createFakeSendClient(); const client = createFakeSendClient();
const stream = createFakeStream(); const stream = createFakeStream();
@@ -430,6 +462,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [], attachments: [],
encodeImages: async () => [], encodeImages: async () => [],
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer", activeTurnBehavior: "steer",
}); });
@@ -446,6 +480,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [], attachments: [],
encodeImages: async () => [], encodeImages: async () => [],
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer", activeTurnBehavior: "steer",
activeTurnId: "turn-1", activeTurnId: "turn-1",
}); });
@@ -458,6 +494,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [], attachments: [],
encodeImages: async () => [], encodeImages: async () => [],
submission: legacy, submission: legacy,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer", activeTurnBehavior: "steer",
}); });
expect(legacy.tail.get("legacy")?.[0]?.turnId).toBeUndefined(); expect(legacy.tail.get("legacy")?.[0]?.turnId).toBeUndefined();
@@ -476,6 +514,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [], attachments: [],
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}), }),
).rejects.toBe(rejection); ).rejects.toBe(rejection);
@@ -496,6 +536,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [], attachments: [],
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}), }),
).rejects.toBe(transportError); ).rejects.toBe(transportError);
@@ -519,6 +561,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [], attachments: [],
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission, submission,
isAgentReady: true,
queue: createFakeQueue(),
}), }),
).rejects.toBe(transportError); ).rejects.toBe(transportError);
}); });
@@ -540,6 +584,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [], attachments: [],
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission, submission,
isAgentReady: true,
queue: createFakeQueue(),
activeTurnBehavior: "steer", activeTurnBehavior: "steer",
}), }),
).rejects.toBe(transportError); ).rejects.toBe(transportError);
@@ -560,6 +606,8 @@ describe("dispatchComposerAgentMessage", () => {
], ],
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}); });
expect(client.calls).toHaveLength(1); expect(client.calls).toHaveLength(1);
@@ -606,6 +654,8 @@ describe("dispatchComposerAgentMessage", () => {
attachmentSubmitFormat: "legacy-github", attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}); });
expect(client.calls[0].options.attachments).toEqual([ expect(client.calls[0].options.attachments).toEqual([
@@ -639,6 +689,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [], attachments: [],
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}); });
expect(stream.head.get("agent")).toHaveLength(2); expect(stream.head.get("agent")).toHaveLength(2);
@@ -656,6 +708,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [], attachments: [],
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}); });
expect(client.calls[0]?.options).toMatchObject({ expect(client.calls[0]?.options).toMatchObject({
@@ -676,6 +730,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [review], attachments: [review],
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}); });
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]); expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
@@ -694,6 +750,8 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [browserElement], attachments: [browserElement],
encodeImages: passthroughEncodeImages, encodeImages: passthroughEncodeImages,
submission: stream, submission: stream,
isAgentReady: true,
queue: createFakeQueue(),
}); });
expect(client.calls[0]?.options.attachments).toEqual([ expect(client.calls[0]?.options.attachments).toEqual([
@@ -796,6 +854,7 @@ describe("sendQueuedComposerMessageNow", () => {
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = []; const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({ const result = await sendQueuedComposerMessageNow({
agentId: "agent", agentId: "agent",
isAgentReady: true,
messageId: "msg-1", messageId: "msg-1",
queue, queue,
submitMessage: async (input) => { submitMessage: async (input) => {
@@ -814,6 +873,7 @@ describe("sendQueuedComposerMessageNow", () => {
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = []; const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
const result = await sendQueuedComposerMessageNow({ const result = await sendQueuedComposerMessageNow({
agentId: "agent", agentId: "agent",
isAgentReady: true,
messageId: "msg-1", messageId: "msg-1",
queue, queue,
submitMessage: async (input) => { submitMessage: async (input) => {
@@ -825,6 +885,27 @@ describe("sendQueuedComposerMessageNow", () => {
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]); 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 () => { it("restores the queued entry to the front and surfaces the error message on failure", async () => {
const queue = createFakeQueue( const queue = createFakeQueue(
new Map([ new Map([
@@ -839,6 +920,7 @@ describe("sendQueuedComposerMessageNow", () => {
); );
const result = await sendQueuedComposerMessageNow({ const result = await sendQueuedComposerMessageNow({
agentId: "agent", agentId: "agent",
isAgentReady: true,
messageId: "msg-1", messageId: "msg-1",
queue, queue,
submitMessage: async () => { submitMessage: async () => {
+16 -2
View File
@@ -170,13 +170,14 @@ export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(in
export interface CancelComposerAgentInput { export interface CancelComposerAgentInput {
client: ComposerCancelClient | null; client: ComposerCancelClient | null;
agentId: string; agentId: string;
isAgentPublished: boolean;
isAgentRunning: boolean; isAgentRunning: boolean;
isCancellingAgent: boolean; isCancellingAgent: boolean;
isConnected: boolean; isConnected: boolean;
} }
export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<void> | null { export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<void> | null {
if (!input.isAgentRunning || input.isCancellingAgent) return null; if (!input.isAgentPublished || !input.isAgentRunning || input.isCancellingAgent) return null;
if (!input.isConnected || !input.client) return null; if (!input.isConnected || !input.client) return null;
try { try {
return Promise.resolve(input.client.cancelAgent(input.agentId)); return Promise.resolve(input.client.cancelAgent(input.agentId));
@@ -188,6 +189,9 @@ export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<vo
export interface DispatchComposerAgentMessageInput { export interface DispatchComposerAgentMessageInput {
client: ComposerSendClient; client: ComposerSendClient;
agentId: string; agentId: string;
/** See isAgentReadyForPrompts. Prompts for an agent that is not ready wait in its queue. */
isAgentReady: boolean;
queue: QueueWriter;
text: string; text: string;
attachments: ComposerAttachment[]; attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat; attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
@@ -201,7 +205,12 @@ export interface DispatchComposerAgentMessageInput {
export async function dispatchComposerAgentMessage( export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput, input: DispatchComposerAgentMessageInput,
): Promise<void> { ): 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";
}
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, { const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat, format: input.attachmentSubmitFormat,
}); });
@@ -226,6 +235,7 @@ export async function dispatchComposerAgentMessage(
attachments: wirePayload.attachments, attachments: wirePayload.attachments,
}); });
input.submission.accept(input.agentId, clientMessageId); input.submission.accept(input.agentId, clientMessageId);
return "sent";
} catch (error) { } catch (error) {
input.submission.reject(input.agentId, clientMessageId); input.submission.reject(input.agentId, clientMessageId);
throw error; throw error;
@@ -293,6 +303,8 @@ export function editQueuedComposerMessage(
export interface SendQueuedComposerMessageNowInput { export interface SendQueuedComposerMessageNowInput {
agentId: string; agentId: string;
/** See DispatchComposerAgentMessageInput. A queued prompt stays in place until then. */
isAgentReady: boolean;
messageId: string; messageId: string;
queue: QueueWriter; queue: QueueWriter;
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>; submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
@@ -301,6 +313,7 @@ export interface SendQueuedComposerMessageNowInput {
export type SendQueuedComposerMessageNowResult = export type SendQueuedComposerMessageNowResult =
| { status: "missing" } | { status: "missing" }
| { status: "waiting" }
| { status: "submitted" } | { status: "submitted" }
| { status: "failed"; errorMessage: string }; | { status: "failed"; errorMessage: string };
@@ -309,6 +322,7 @@ export async function sendQueuedComposerMessageNow(
): Promise<SendQueuedComposerMessageNowResult> { ): Promise<SendQueuedComposerMessageNowResult> {
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId); const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
if (!item) return { status: "missing" }; if (!item) return { status: "missing" };
if (!input.isAgentReady) return { status: "waiting" };
input.queue.write((prev) => { input.queue.write((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set( next.set(
@@ -35,6 +35,7 @@ import {
} from "@/provider-selection/provider-selection"; } from "@/provider-selection/provider-selection";
import { filterSelectableModels } from "@/provider-selection/model-catalog"; import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useAgentCreation } from "@/workspace-creation";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions"; import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
@@ -420,6 +421,56 @@ 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( function resolveSnapshotSelectedEntry(
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"], snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
agentProvider: string | undefined, agentProvider: string | undefined,
@@ -1556,14 +1607,13 @@ export const AgentControls = memo(function AgentControls({
isCompactLayout, isCompactLayout,
}: AgentControlsProps) { }: AgentControlsProps) {
const { updatePreferences } = useFormPreferences(); const { updatePreferences } = useFormPreferences();
const agent = useSessionStore( const { agent, isPublished, canEdit, submittedModeId } = useAgentControlsSource(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)), serverId,
agentId,
); );
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const toast = useToast(); const toast = useToast();
const modeControl = useLiveAgentModeControl(serverId, agentId); const liveModeControl = useLiveAgentModeControl(serverId, agentId);
const commandCenterModes = toCommandCenterModes(modeControl);
const modeProviderDefinitions = getModeProviderDefinitions(modeControl);
const { const {
entries: snapshotEntries, entries: snapshotEntries,
@@ -1586,6 +1636,26 @@ export const AgentControls = memo(function AgentControls({
[agent?.provider, snapshotEntries], [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( const agentProviderModels = useMemo(
() => buildAgentProviderModels(agent?.provider, models), () => buildAgentProviderModels(agent?.provider, models),
[agent?.provider, models], [agent?.provider, models],
@@ -1769,7 +1839,7 @@ export const AgentControls = memo(function AgentControls({
const commandCenterRegistration = ( const commandCenterRegistration = (
<AgentControlCommandCenterRegistration <AgentControlCommandCenterRegistration
sourceId={`agent:${serverId}:${agentId}`} sourceId={`agent:${serverId}:${agentId}`}
enabled={Boolean(client)} enabled={canEdit}
controls={commandCenterControls} controls={commandCenterControls}
/> />
); );
@@ -1814,7 +1884,7 @@ export const AgentControls = memo(function AgentControls({
onRetryModelProvider={handleRetryModelProvider} onRetryModelProvider={handleRetryModelProvider}
isRetryingModelProvider={snapshotIsRefreshing} isRetryingModelProvider={snapshotIsRefreshing}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
disabled={!client} disabled={!canEdit}
modeControl={modeControl} modeControl={modeControl}
modelSelectorServerId={serverId} modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout} isCompactLayout={isCompactLayout}
@@ -63,6 +63,7 @@ export interface AgentInputDraft {
attachments: UserComposerAttachment[]; attachments: UserComposerAttachment[];
setAttachments: (updater: AttachmentUpdater) => void; setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void; clear: (lifecycle: "sent" | "abandoned") => void;
flush: () => void;
isHydrated: boolean; isHydrated: boolean;
attachmentFocusRequestId: number; attachmentFocusRequestId: number;
composerState: DraftComposerState | null; composerState: DraftComposerState | null;
@@ -172,6 +173,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[draftKey, textPublication], [draftKey, textPublication],
); );
const flushDraft = useCallback(() => textPublication.flush(), [textPublication]);
const replaceText = useCallback( const replaceText = useCallback(
(nextText: string) => { (nextText: string) => {
textPublication.cancel(); textPublication.cancel();
@@ -351,6 +354,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
return { return {
textSource, textSource,
editText, editText,
flush: flushDraft,
replaceText, replaceText,
textReplacement, textReplacement,
attachments, attachments,
@@ -49,6 +49,7 @@ import {
} from "@/workspace-tabs/model"; } from "@/workspace-tabs/model";
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings"; import { useSettings } from "@/hooks/use-settings";
import { transferComposerFollowUps } from "@/composer/submission/writer";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -184,8 +185,7 @@ async function submitDraftCreateRequest(input: {
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}), ...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
}; };
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId]; const result = await client.createAgent(options);
const result = creation ? await creation.retry(options) : await client.createAgent(options);
return { return {
agentId: result.id, agentId: result.id,
@@ -466,10 +466,6 @@ export function WorkspaceDraftAgentTab({
selectModelMessage: t("workspaceSetup.errors.selectModel"), selectModelMessage: t("workspaceSetup.errors.selectModel"),
}), }),
createRequest: async ({ attempt, text, images, attachments, cwd }) => { createRequest: async ({ attempt, text, images, attachments, cwd }) => {
if (pendingAutoSubmit?.agentCreation) {
const result = await pendingAutoSubmit.agentCreation.result;
return { agentId: result.id, result };
}
return submitDraftCreateRequest({ return submitDraftCreateRequest({
draftId, draftId,
attempt, attempt,
@@ -491,6 +487,7 @@ export function WorkspaceDraftAgentTab({
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
onCreated(result); onCreated(result);
transferComposerFollowUps(serverId, tabId, result.id);
}, },
}); });
const turnPresentation = useMemo( const turnPresentation = useMemo(
+31 -25
View File
@@ -58,6 +58,7 @@ import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { import {
selectAgentSentMessages, selectAgentSentMessages,
selectAgentTurnPresentation, selectAgentTurnPresentation,
selectIsAgentPublished,
useSessionStore, useSessionStore,
} from "@/stores/session-store"; } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker"; import { useFilePicker } from "@/hooks/use-file-picker";
@@ -88,9 +89,9 @@ import {
toggleForgeAttachmentFromPicker, toggleForgeAttachmentFromPicker,
uploadFileAttachments, uploadFileAttachments,
type AttachmentPersister, type AttachmentPersister,
type QueueWriter,
type QueuedComposerMessage, type QueuedComposerMessage,
} from "@/composer/actions"; } from "@/composer/actions";
import { isAgentReadyForPrompts } from "@/workspace-creation";
import { useVoiceOptional } from "@/contexts/voice-context"; import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -123,7 +124,10 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit"; import { submitAgentInput } from "@/composer/submit";
import { ComposerRecall } from "@/composer/recall"; import { ComposerRecall } from "@/composer/recall";
import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { RenderProfile } from "@/utils/render-profiler"; import { RenderProfile } from "@/utils/render-profiler";
@@ -258,8 +262,8 @@ function resolveCheckoutRemoteUrl(
return checkoutStatus?.remoteUrl ?? null; return checkoutStatus?.remoteUrl ?? null;
} }
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] { function buildCancelButtonStyle(canCancel: boolean, isCancellingAgent: boolean): object[] {
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined; const disabled = !canCancel || isCancellingAgent ? styles.buttonDisabled : undefined;
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value)); return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
} }
@@ -1083,7 +1087,7 @@ interface ComposerCancelButtonProps {
buttonIconSize: number; buttonIconSize: number;
cancelButtonStyle: (object | undefined)[]; cancelButtonStyle: (object | undefined)[];
handleCancelAgent: () => void; handleCancelAgent: () => void;
isConnected: boolean; canCancel: boolean;
isCancellingAgent: boolean; isCancellingAgent: boolean;
agentInterruptKeys: ReturnType<typeof useShortcutKeys>; agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction; t: TFunction;
@@ -1093,7 +1097,7 @@ function ComposerCancelButton({
buttonIconSize, buttonIconSize,
cancelButtonStyle, cancelButtonStyle,
handleCancelAgent, handleCancelAgent,
isConnected, canCancel,
isCancellingAgent, isCancellingAgent,
agentInterruptKeys, agentInterruptKeys,
t, t,
@@ -1111,7 +1115,7 @@ function ComposerCancelButton({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={handleCancelAgent} onPress={handleCancelAgent}
disabled={!isConnected || isCancellingAgent} disabled={!canCancel || isCancellingAgent}
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
accessibilityRole="button" accessibilityRole="button"
style={cancelButtonStyle} style={cancelButtonStyle}
@@ -1306,8 +1310,6 @@ function ComposerContentImpl({
); );
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const isCompactFormFactor = useIsCompactFormFactor(); const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
@@ -1570,9 +1572,12 @@ function ComposerContentImpl({
if (!client) { if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected")); throw new Error(t("workspace.terminal.hostDisconnected"));
} }
const session = useSessionStore.getState().sessions[serverId];
await dispatchComposerAgentMessage({ await dispatchComposerAgentMessage({
client, client,
agentId: targetAgentId, agentId: targetAgentId,
isAgentReady: isAgentReadyForPrompts(serverId, targetAgentId),
queue: createAgentMessageQueueWriter(serverId),
text, text,
attachments: sendAttachments, attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({ attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
@@ -1583,10 +1588,7 @@ function ComposerContentImpl({
activeTurnBehavior, activeTurnBehavior,
activeTurnId: activeTurnId:
activeTurnBehavior === "steer" activeTurnBehavior === "steer"
? (selectAgentTurnPresentation( ? (selectAgentTurnPresentation(session, targetAgentId).turnId ?? undefined)
useSessionStore.getState().sessions[serverId],
targetAgentId,
).turnId ?? undefined)
: undefined, : undefined,
}); });
onAttentionPromptSend?.(); onAttentionPromptSend?.();
@@ -1620,13 +1622,7 @@ function ComposerContentImpl({
); );
const hasAgent = agentState.status !== null; const hasAgent = agentState.status !== null;
const queueWriter = useMemo<QueueWriter>( const queueWriter = useMemo(() => createAgentMessageQueueWriter(serverId), [serverId]);
() => ({
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
write: (updater) => setQueuedMessages(serverId, updater),
}),
[serverId, setQueuedMessages],
);
const queueMessage = useCallback( const queueMessage = useCallback(
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => { (queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
@@ -1896,6 +1892,10 @@ function ComposerContentImpl({
const cancellation = cancelComposerAgent({ const cancellation = cancelComposerAgent({
client, client,
agentId: targetAgentId, agentId: targetAgentId,
isAgentPublished: selectIsAgentPublished(
useSessionStore.getState().sessions[serverId],
targetAgentId,
),
isAgentRunning, isAgentRunning,
isCancellingAgent, isCancellingAgent,
isConnected, isConnected,
@@ -1952,6 +1952,7 @@ function ComposerContentImpl({
// Reuse the regular send path; server-side send atomically interrupts any active run. // Reuse the regular send path; server-side send atomically interrupts any active run.
const result = await sendQueuedComposerMessageNow({ const result = await sendQueuedComposerMessageNow({
agentId, agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
messageId: id, messageId: id,
queue: queueWriter, queue: queueWriter,
submitMessage: ({ text, attachments: queuedAttachments }) => submitMessage: ({ text, attachments: queuedAttachments }) =>
@@ -1962,7 +1963,7 @@ function ComposerContentImpl({
setSendError(result.errorMessage); setSendError(result.errorMessage);
} }
}, },
[agentId, queueWriter, submitMessage, t], [agentId, queueWriter, serverId, submitMessage, t],
); );
const handleQueue = useCallback( const handleQueue = useCallback(
@@ -2010,9 +2011,14 @@ function ComposerContentImpl({
[agentId, recall, replaceUserInput, serverId], [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( const cancelButtonStyle = useMemo(
() => buildCancelButtonStyle(isConnected, isCancellingAgent), () => buildCancelButtonStyle(canCancelAgent, isCancellingAgent),
[isConnected, isCancellingAgent], [canCancelAgent, isCancellingAgent],
); );
const isVoiceSwitching = voice?.isVoiceSwitching ?? false; const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
@@ -2029,7 +2035,7 @@ function ComposerContentImpl({
buttonIconSize={buttonIconSize} buttonIconSize={buttonIconSize}
cancelButtonStyle={cancelButtonStyle} cancelButtonStyle={cancelButtonStyle}
handleCancelAgent={handleCancelAgent} handleCancelAgent={handleCancelAgent}
isConnected={isConnected} canCancel={canCancelAgent}
isCancellingAgent={isCancellingAgent} isCancellingAgent={isCancellingAgent}
agentInterruptKeys={agentInterruptKeys} agentInterruptKeys={agentInterruptKeys}
t={t} t={t}
@@ -2039,9 +2045,9 @@ function ComposerContentImpl({
agentInterruptKeys, agentInterruptKeys,
buttonIconSize, buttonIconSize,
cancelButtonStyle, cancelButtonStyle,
canCancelAgent,
handleCancelAgent, handleCancelAgent,
isCancellingAgent, isCancellingAgent,
isConnected,
t, t,
], ],
); );
@@ -2,7 +2,9 @@ import { afterEach, expect, test } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import type { UserMessageItem } from "@/types/stream"; import type { UserMessageItem } from "@/types/stream";
import { createMessageSubmissionWriter } from "./writer"; import { useDraftStore } from "@/stores/draft-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { createMessageSubmissionWriter, transferComposerFollowUps } from "./writer";
const CANONICAL_ONLY_SERVER_ID = "canonical-only"; const CANONICAL_ONLY_SERVER_ID = "canonical-only";
@@ -47,3 +49,59 @@ test("tracks submissions when canonical prompts are supported", () => {
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]); 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);
});
+49 -1
View File
@@ -1,5 +1,7 @@
import type { MessageSubmissionWriter } from "@/composer/actions"; import type { MessageSubmissionWriter, QueueWriter } from "@/composer/actions";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useDraftStore, type DraftInput } from "@/stores/draft-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { import {
appendSubmittedUserMessage, appendSubmittedUserMessage,
removeSubmittedUserMessage, removeSubmittedUserMessage,
@@ -63,6 +65,14 @@ 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( export function handoffCreatedAgentMessageSubmission(
serverId: string, serverId: string,
agentId: string, agentId: string,
@@ -70,3 +80,41 @@ export function handoffCreatedAgentMessageSubmission(
): boolean { ): boolean {
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message); 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,3 +1,4 @@
import type { CreationAttempt } from "@/workspace-creation/model";
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status"; import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
import equal from "fast-deep-equal"; import equal from "fast-deep-equal";
import type { PrHint } from "@/git/pr-hint"; import type { PrHint } from "@/git/pr-hint";
@@ -146,6 +147,7 @@ export function createSidebarWorkspaceEntry(input: {
workspace: WorkspaceDescriptor; workspace: WorkspaceDescriptor;
projectViewKey?: string; projectViewKey?: string;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>; pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>; workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): SidebarWorkspaceEntry { }): SidebarWorkspaceEntry {
const projectViewKey = input.projectViewKey ?? input.workspace.projectId; const projectViewKey = input.projectViewKey ?? input.workspace.projectId;
@@ -203,6 +205,7 @@ export function deriveProjectStatusBucket(input: {
workspaces: readonly SidebarWorkspacePlacement[]; workspaces: readonly SidebarWorkspacePlacement[];
sessions: Record<string, ProjectStatusSession | undefined>; sessions: Record<string, ProjectStatusSession | undefined>;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>; pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
}): SidebarStateBucket { }): SidebarStateBucket {
const workspaceIdsByServer = new Map<string, string[]>(); const workspaceIdsByServer = new Map<string, string[]>();
for (const placement of input.workspaces) { for (const placement of input.workspaces) {
@@ -230,6 +233,7 @@ export function deriveProjectStatusBucket(input: {
serverId, serverId,
workspace, workspace,
pendingCreateAttempts: input.pendingCreateAttempts, pendingCreateAttempts: input.pendingCreateAttempts,
workspaceCreations: input.workspaceCreations,
workspaceAgentActivity: session.workspaceAgentActivity, workspaceAgentActivity: session.workspaceAgentActivity,
}).status, }).status,
); );
@@ -348,6 +352,7 @@ export function buildSidebarWorkspaceEntries(input: {
placements: readonly SidebarWorkspacePlacement[]; placements: readonly SidebarWorkspacePlacement[];
sessions: SidebarWorkspaceSession[]; sessions: SidebarWorkspaceSession[];
pendingCreateAttempts?: Record<string, PendingCreateAttempt>; pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>; previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
}): ReadonlyMap<string, SidebarWorkspaceEntry> { }): ReadonlyMap<string, SidebarWorkspaceEntry> {
if (input.placements.length === 0 || input.sessions.length === 0) { if (input.placements.length === 0 || input.sessions.length === 0) {
@@ -374,6 +379,7 @@ export function buildSidebarWorkspaceEntries(input: {
workspace, workspace,
projectViewKey: placement.projectViewKey, projectViewKey: placement.projectViewKey,
pendingCreateAttempts: input.pendingCreateAttempts, pendingCreateAttempts: input.pendingCreateAttempts,
workspaceCreations: input.workspaceCreations,
workspaceAgentActivity: session.workspaceAgentActivity, workspaceAgentActivity: session.workspaceAgentActivity,
}); });
const previousEntry = input.previousEntries?.get(placement.workspaceKey); const previousEntry = input.previousEntries?.get(placement.workspaceKey);
@@ -1,3 +1,6 @@
import { useTranslation } from "react-i18next";
import { useWorkspaceCreations } from "@/workspace-creation";
import { creatingSidebarEntry } from "@/workspace-creation/presentation";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -19,6 +22,7 @@ export function useSidebarWorkspaceEntries(
placements: readonly SidebarWorkspacePlacement[], placements: readonly SidebarWorkspacePlacement[],
enabled = true, enabled = true,
): ReadonlyMap<string, SidebarWorkspaceEntry> { ): ReadonlyMap<string, SidebarWorkspaceEntry> {
const { t } = useTranslation();
const serverIds = useMemo( const serverIds = useMemo(
() => Array.from(new Set(placements.map((placement) => placement.serverId))), () => Array.from(new Set(placements.map((placement) => placement.serverId))),
[placements], [placements],
@@ -32,6 +36,7 @@ export function useSidebarWorkspaceEntries(
const pendingCreateAttempts = useCreateFlowStore((state) => const pendingCreateAttempts = useCreateFlowStore((state) =>
enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS, enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
); );
const creations = useWorkspaceCreations();
const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES); const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES);
// Collection ownership is intentional: retained sidebars have one cheap // Collection ownership is intentional: retained sidebars have one cheap
@@ -45,13 +50,26 @@ export function useSidebarWorkspaceEntries(
previousEntriesRef.current = EMPTY_ENTRIES; previousEntriesRef.current = EMPTY_ENTRIES;
return EMPTY_ENTRIES; return EMPTY_ENTRIES;
} }
const entries = buildSidebarWorkspaceEntries({ const entries = new Map(
placements, buildSidebarWorkspaceEntries({
sessions, placements,
pendingCreateAttempts, sessions,
previousEntries: previousEntriesRef.current, 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));
}
previousEntriesRef.current = entries; previousEntriesRef.current = entries;
return entries; return entries;
}, [enabled, pendingCreateAttempts, placements, sessions]); }, [enabled, pendingCreateAttempts, placements, sessions, creations, t]);
} }
@@ -1,3 +1,6 @@
import { useTranslation } from "react-i18next";
import { useWorkspaceCreations } from "@/workspace-creation";
import { addCreatingSidebarWorkspaces } from "@/workspace-creation/presentation";
import { useHostFeatureMap } from "@/runtime/host-features"; import { useHostFeatureMap } from "@/runtime/host-features";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
@@ -59,6 +62,7 @@ export function useSidebarProjectStatusBucket(input: {
enabled: boolean; enabled: boolean;
}): SidebarStateBucket | null { }): SidebarStateBucket | null {
const { workspaces, enabled } = input; const { workspaces, enabled } = input;
const workspaceCreations = useWorkspaceCreations();
const pendingCreateAttempts = useStoreWithEqualityFn( const pendingCreateAttempts = useStoreWithEqualityFn(
useCreateFlowStore, useCreateFlowStore,
(state) => state.pendingByDraftId, (state) => state.pendingByDraftId,
@@ -72,9 +76,10 @@ export function useSidebarProjectStatusBucket(input: {
workspaces, workspaces,
sessions: state.sessions, sessions: state.sessions,
pendingCreateAttempts, pendingCreateAttempts,
workspaceCreations,
}); });
}, },
[enabled, pendingCreateAttempts, workspaces], [enabled, pendingCreateAttempts, workspaces, workspaceCreations],
); );
return useStoreWithEqualityFn(useSessionStore, selector, Object.is); return useStoreWithEqualityFn(useSessionStore, selector, Object.is);
@@ -150,10 +155,12 @@ export function useSidebarWorkspacesList(options?: {
: undefined; : undefined;
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort); const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
const { t } = useTranslation();
const creations = useWorkspaceCreations();
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []); const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
const sidebarModel = useMemo( const sidebarModel = useMemo(
() => selectPlacements(hostProjects), () => addCreatingSidebarWorkspaces(selectPlacements(hostProjects), creations, t),
[hostProjects, selectPlacements], [hostProjects, selectPlacements, creations, t],
); );
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS; const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
+9
View File
@@ -1339,6 +1339,15 @@ export const ar: TranslationResources = {
}, },
}, },
newWorkspace: { newWorkspace: {
creation: {
creatingWorkspace: "جارٍ إنشاء مساحة العمل…",
creatingLabel: "جارٍ إنشاء مساحة العمل",
updateHost: "حدّث هذا المضيف لإنشاء مساحة عمل تتضمن وكيلاً.",
chooseProjectHost: "اختر مشروعًا ومضيفًا",
projectUnavailable: "المشروع غير متاح على المضيف المحدد",
workspacePending: "لا يزال إنشاء مساحة العمل جاريًا.",
agentPending: "لا يزال إنشاء الوكيل جاريًا.",
},
title: "مساحة عمل جديدة", title: "مساحة عمل جديدة",
create: "يخلق", create: "يخلق",
isolation: { isolation: {
+9
View File
@@ -1347,6 +1347,15 @@ export const en = {
}, },
}, },
newWorkspace: { 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", title: "New workspace",
create: "Create", create: "Create",
isolation: { isolation: {
+9
View File
@@ -1375,6 +1375,15 @@ export const es: TranslationResources = {
}, },
}, },
newWorkspace: { 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", title: "Nuevo espacio de trabajo",
create: "Crear", create: "Crear",
isolation: { isolation: {
+9
View File
@@ -1369,6 +1369,15 @@ export const fr: TranslationResources = {
}, },
}, },
newWorkspace: { 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", title: "Nouvel espace de travail",
create: "Créer", create: "Créer",
isolation: { isolation: {
+9
View File
@@ -1353,6 +1353,15 @@ export const ja: TranslationResources = {
}, },
}, },
newWorkspace: { newWorkspace: {
creation: {
creatingWorkspace: "ワークスペースを作成中…",
creatingLabel: "ワークスペースを作成中",
updateHost: "エージェント付きのワークスペースを作成するには、このホストを更新してください。",
chooseProjectHost: "プロジェクトとホストを選択してください",
projectUnavailable: "選択したホストでプロジェクトを利用できません",
workspacePending: "ワークスペースはまだ作成中です。",
agentPending: "エージェントはまだ作成中です。",
},
title: "新しいワークスペース", title: "新しいワークスペース",
create: "作成", create: "作成",
isolation: { isolation: {
+9
View File
@@ -1346,6 +1346,15 @@ export const ko: TranslationResources = {
}, },
}, },
newWorkspace: { newWorkspace: {
creation: {
creatingWorkspace: "워크스페이스 생성 중…",
creatingLabel: "워크스페이스 생성 중",
updateHost: "에이전트가 있는 워크스페이스를 만들려면 이 호스트를 업데이트하세요.",
chooseProjectHost: "프로젝트와 호스트를 선택하세요",
projectUnavailable: "선택한 호스트에서 프로젝트를 사용할 수 없습니다",
workspacePending: "워크스페이스가 아직 생성 중입니다.",
agentPending: "에이전트가 아직 생성 중입니다.",
},
title: "새 워크스페이스", title: "새 워크스페이스",
create: "생성", create: "생성",
isolation: { isolation: {
+9
View File
@@ -1365,6 +1365,15 @@ export const ptBR: TranslationResources = {
}, },
}, },
newWorkspace: { 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", title: "Novo workspace",
create: "Criar", create: "Criar",
isolation: { isolation: {
+9
View File
@@ -1357,6 +1357,15 @@ export const ru: TranslationResources = {
}, },
}, },
newWorkspace: { newWorkspace: {
creation: {
creatingWorkspace: "Создание рабочей области…",
creatingLabel: "Создание рабочей области",
updateHost: "Обновите этот хост, чтобы создать рабочую область с агентом.",
chooseProjectHost: "Выберите проект и хост",
projectUnavailable: "Проект недоступен на выбранном хосте",
workspacePending: "Рабочая область ещё создаётся.",
agentPending: "Агент ещё создаётся.",
},
title: "Новое рабочее пространство", title: "Новое рабочее пространство",
create: "Создать", create: "Создать",
isolation: { isolation: {
+9
View File
@@ -1328,6 +1328,15 @@ export const zhCN: TranslationResources = {
}, },
}, },
newWorkspace: { newWorkspace: {
creation: {
creatingWorkspace: "正在创建工作区…",
creatingLabel: "正在创建工作区",
updateHost: "请更新此主机,以创建包含代理的工作区。",
chooseProjectHost: "请选择项目和主机",
projectUnavailable: "所选主机上没有此项目",
workspacePending: "工作区仍在创建中。",
agentPending: "代理仍在创建中。",
},
title: "新建 workspace", title: "新建 workspace",
create: "创建", create: "创建",
isolation: { isolation: {
+30 -8
View File
@@ -1,3 +1,5 @@
import { useAgentCreation } from "@/workspace-creation";
import { pendingCreationAgent } from "@/workspace-creation/presentation";
import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -472,6 +474,7 @@ function AgentPanelContent({
); );
}); });
const creation = useAgentCreation(serverId, agentId);
const connectionServerId = resolvedServerId ?? null; const connectionServerId = resolvedServerId ?? null;
const daemon = connectionServerId const daemon = connectionServerId
? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null) ? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null)
@@ -485,7 +488,7 @@ function AgentPanelContent({
: runtimeConnectionStatus; : runtimeConnectionStatus;
const lastConnectionError = runtimeLastError; const lastConnectionError = runtimeLastError;
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) { if (!resolvedServerId || (!runtimeClient && !hasCachedAgent && !creation)) {
return ( return (
<AgentSessionUnavailableState <AgentSessionUnavailableState
serverLabel={serverLabel} serverLabel={serverLabel}
@@ -548,6 +551,8 @@ function AgentPanelBody({
const agentState = useSessionStore( const agentState = useSessionStore(
useShallow((state) => selectChatAgentState(state, serverId, agentId)), 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 [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
const lookupAttemptTokenRef = useRef(0); const lookupAttemptTokenRef = useRef(0);
const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []); const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []);
@@ -558,7 +563,7 @@ function AgentPanelBody({
}, [agentId, serverId]); }, [agentId, serverId]);
useEffect(() => { useEffect(() => {
if (!agentId) { if (!agentId || isCreating) {
return; return;
} }
if (agentState.id) { if (agentState.id) {
@@ -606,7 +611,16 @@ function AgentPanelBody({
} }
setLookupState({ tag: "error", message }); setLookupState({ tag: "error", message });
}); });
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]); }, [
agentId,
agentState.id,
client,
hasSession,
isConnected,
isCreating,
lookupState.tag,
serverId,
]);
if (lookupState.tag === "not_found") { if (lookupState.tag === "not_found") {
return ( return (
@@ -642,7 +656,7 @@ function AgentPanelBody({
} }
: null; : null;
if (!agent) { if (!agent && !isCreating) {
return ( return (
<View style={styles.container} testID="agent-loading"> <View style={styles.container} testID="agent-loading">
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
@@ -755,6 +769,7 @@ function ChatAgentContent({
const hasActiveCreateHandoff = useCreateFlowStore((state) => const hasActiveCreateHandoff = useCreateFlowStore((state) =>
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }), findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
); );
const creation = useAgentCreation(serverId, agentId ?? "");
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({ const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
kind: "idle", kind: "idle",
@@ -812,6 +827,9 @@ function ChatAgentContent({
[agentState, projectPlacement], [agentState, projectPlacement],
); );
const continuity = useMemo<AgentScreenContinuity>(() => { const continuity = useMemo<AgentScreenContinuity>(() => {
if (creation) {
return { kind: "optimistic-create", agent: pendingCreationAgent(creation) };
}
if (!hasActiveCreateHandoff || !agentId) { if (!hasActiveCreateHandoff || !agentId) {
return { kind: "none" }; return { kind: "none" };
} }
@@ -825,7 +843,7 @@ function ChatAgentContent({
projectPlacement: agent?.projectPlacement ?? null, projectPlacement: agent?.projectPlacement ?? null,
}, },
}; };
}, [agent, agentId, hasActiveCreateHandoff, serverId]); }, [agent, agentId, creation, hasActiveCreateHandoff, serverId]);
const viewState = useAgentScreenStateMachine({ const viewState = useAgentScreenStateMachine({
routeKey: `${serverId}:${agentId ?? ""}`, routeKey: `${serverId}:${agentId ?? ""}`,
@@ -898,7 +916,7 @@ function ChatAgentContent({
}, [agentId, serverId]); }, [agentId, serverId]);
useEffect(() => { useEffect(() => {
if (!agentId) { if (!agentId || creation) {
return; return;
} }
if (agentState.archivedAt) { if (agentState.archivedAt) {
@@ -979,6 +997,7 @@ function ChatAgentContent({
agentState.archivedAt, agentState.archivedAt,
hasAppliedAuthoritativeHistory, hasAppliedAuthoritativeHistory,
agentId, agentId,
creation,
client, client,
hasSession, hasSession,
isConnected, isConnected,
@@ -998,8 +1017,8 @@ function ChatAgentContent({
if (nonReadyView) return nonReadyView; if (nonReadyView) return nonReadyView;
invariant(agentId, "agent id is defined when agent content is ready"); invariant(agentId, "agent id is defined when agent content is ready");
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent"); invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
const agentCwd = agentState.cwd; // Empty while a creating agent waits for the host to choose its directory.
invariant(agentCwd, "agent cwd is defined when agent content is ready"); const agentCwd = effectiveAgent.cwd;
const showHistorySyncOverlay = const showHistorySyncOverlay =
viewState.tag === "ready" && viewState.tag === "ready" &&
viewState.sync.status === "catching_up" && viewState.sync.status === "catching_up" &&
@@ -1132,6 +1151,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const { const {
textSource, textSource,
editText, editText,
flush,
replaceText, replaceText,
textReplacement, textReplacement,
attachments, attachments,
@@ -1145,6 +1165,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
(): AgentInputDraft => ({ (): AgentInputDraft => ({
textSource, textSource,
editText, editText,
flush,
replaceText, replaceText,
textReplacement, textReplacement,
attachments, attachments,
@@ -1157,6 +1178,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
[ [
textSource, textSource,
editText, editText,
flush,
replaceText, replaceText,
textReplacement, textReplacement,
attachments, attachments,
@@ -10,7 +10,11 @@ import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { dispatchComposerAgentMessage } from "@/composer/actions"; import { dispatchComposerAgentMessage } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { isAgentReadyForPrompts } from "@/workspace-creation";
import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store"; import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store";
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button"; import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -77,6 +81,8 @@ export function useReviewFeedback(input: {
await dispatchComposerAgentMessage({ await dispatchComposerAgentMessage({
client: targetClient, client: targetClient,
agentId: recipient.agentId, agentId: recipient.agentId,
isAgentReady: isAgentReadyForPrompts(serverId, recipient.agentId),
queue: createAgentMessageQueueWriter(serverId),
text: i18n.t("review.feedback.prompt"), text: i18n.t("review.feedback.prompt"),
attachments: [attachment], attachments: [attachment],
encodeImages: async () => [], encodeImages: async () => [],
@@ -21,9 +21,12 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client"; import { queryClient } from "@/data/query-client";
import { workspaceCreations } from "@/workspace-creation/runtime";
import { hasActiveMessageSubmission } from "@/composer/submission/model";
import { import {
HostRuntimeController, HostRuntimeController,
HostRuntimeStore, HostRuntimeStore,
getHostRuntimeStore,
readInitialDaemonConnectionHint, readInitialDaemonConnectionHint,
type HostRuntimeControllerDeps, type HostRuntimeControllerDeps,
type HostRuntimeStorage, type HostRuntimeStorage,
@@ -73,6 +76,8 @@ class FakeDaemonClient {
Awaited<ReturnType<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]> Awaited<ReturnType<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
> = []; > = [];
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = []; public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
public createWorkspaceRequests: Array<Parameters<DaemonClient["createWorkspace"]>[0]> = [];
public createWorkspaceResponse: ReturnType<DaemonClient["createWorkspace"]> | null = null;
public sendAgentMessageFailures: Error[] = []; public sendAgentMessageFailures: Error[] = [];
public sendAgentMessageResponses: Promise<void>[] = []; public sendAgentMessageResponses: Promise<void>[] = [];
private agentUpdateListeners = new Set< private agentUpdateListeners = new Set<
@@ -119,6 +124,14 @@ 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> { async connect(): Promise<void> {
this.connectCalls += 1; this.connectCalls += 1;
this.setConnectionState({ status: "connected" }); this.setConnectionState({ status: "connected" });
@@ -426,6 +439,23 @@ function replicaAgent(snapshot: FetchAgentsEntry["agent"], serverId: string): Ag
return { ...normalizeAgentSnapshot(snapshot, serverId), projectPlacement: null }; 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 { function agentPermission(id: string): AgentPermissionRequest {
return { id, provider: "codex", name: id, kind: "tool", title: id }; return { id, provider: "codex", name: id, kind: "tool", title: id };
} }
@@ -3168,6 +3198,7 @@ describe("HostRuntimeStore", () => {
}); });
const sessionStore = useSessionStore.getState(); const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.updateSessionServerInfo(host.serverId, { sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId, serverId: host.serverId,
hostname: null, hostname: null,
@@ -3233,6 +3264,7 @@ describe("HostRuntimeStore", () => {
}); });
const sessionStore = useSessionStore.getState(); const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.setQueuedMessages( sessionStore.setQueuedMessages(
host.serverId, host.serverId,
new Map([ new Map([
@@ -3279,6 +3311,7 @@ describe("HostRuntimeStore", () => {
}); });
const sessionStore = useSessionStore.getState(); const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.setQueuedMessages( sessionStore.setQueuedMessages(
host.serverId, host.serverId,
new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]), new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]),
@@ -3298,6 +3331,212 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId); 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 () => { 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 host = makeHost({ serverId: "srv_steer_queue_drain" });
const fakeClient = new FakeDaemonClient(); const fakeClient = new FakeDaemonClient();
@@ -3314,6 +3553,7 @@ describe("HostRuntimeStore", () => {
}); });
const sessionStore = useSessionStore.getState(); const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.setQueuedMessages( sessionStore.setQueuedMessages(
host.serverId, host.serverId,
new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]), new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]),
@@ -3342,6 +3582,7 @@ describe("HostRuntimeStore", () => {
}); });
const sessionStore = useSessionStore.getState(); const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
publishAgent(host.serverId, "agent");
sessionStore.updateSessionServerInfo(host.serverId, { sessionStore.updateSessionServerInfo(host.serverId, {
serverId: host.serverId, serverId: host.serverId,
hostname: null, hostname: null,
+12 -8
View File
@@ -73,7 +73,11 @@ import {
import { mountBrowserAutomationDaemonClientHandler } from "@/desktop/browser/automation/handler"; import { mountBrowserAutomationDaemonClientHandler } from "@/desktop/browser/automation/handler";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions"; import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { isAgentReadyForPrompts } from "@/workspace-creation";
import {
createAgentMessageQueueWriter,
createMessageSubmissionWriter,
} from "@/composer/submission/writer";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
@@ -2568,20 +2572,20 @@ export class HostRuntimeStore {
} }
this.queuedAgentDrainInFlight.add(drainKey); this.queuedAgentDrainInFlight.add(drainKey);
const next = queue[0]; const next = queue[0];
const queueWriter = createAgentMessageQueueWriter(serverId);
void sendQueuedComposerMessageNow({ void sendQueuedComposerMessageNow({
agentId, agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
messageId: next.id, messageId: next.id,
queue: { queue: queueWriter,
read: (queuedAgentId) =>
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
},
submitMessage: async ({ text, attachments }) => { submitMessage: async ({ text, attachments }) => {
const supportsForgeAttachments = const currentSession = useSessionStore.getState().sessions[serverId];
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true; const supportsForgeAttachments = currentSession?.serverInfo?.features?.forgeSearch === true;
await dispatchComposerAgentMessage({ await dispatchComposerAgentMessage({
client, client,
agentId, agentId,
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
queue: queueWriter,
text, text,
attachments, attachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({ attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
@@ -163,6 +163,25 @@ describe("clearPickerPrAttachmentForTargetChange", () => {
}); });
describe("reducePickerSelection", () => { 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", () => { it("selects a PR that was newly detected and added", () => {
const item = { kind: "github-pr" as const, item: makePrItem(101, "A") }; const item = { kind: "github-pr" as const, item: makePrItem(101, "A") };
const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" }); const detected = reducePickerSelection(initialPickerSelectionState, { type: "pr-detected" });
@@ -13,6 +13,7 @@ export type PickerSelectionEvent =
| { type: "pr-detected" } | { type: "pr-detected" }
| { type: "pr-added"; item: Extract<PickerItem, { kind: "github-pr" }> } | { type: "pr-added"; item: Extract<PickerItem, { kind: "github-pr" }> }
| { type: "picker-selected"; item: PickerItem } | { type: "picker-selected"; item: PickerItem }
| { type: "draft-restored"; item: PickerItem | null }
| { type: "target-changed" }; | { type: "target-changed" };
export const initialPickerSelectionState: PickerSelectionState = { export const initialPickerSelectionState: PickerSelectionState = {
@@ -33,6 +34,8 @@ export function reducePickerSelection(
: state; : state;
case "picker-selected": case "picker-selected":
return { selectedItem: event.item, allowAutoPrSelection: false }; return { selectedItem: event.item, allowAutoPrSelection: false };
case "draft-restored":
return state.selectedItem ? state : { selectedItem: event.item, allowAutoPrSelection: false };
case "target-changed": case "target-changed":
return initialPickerSelectionState; return initialPickerSelectionState;
} }
+54 -381
View File
@@ -1,10 +1,7 @@
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { getHostRuntimeStore } from "@/runtime/host-runtime";
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { submitWorkspaceChat, takeWorkspaceCreationFailure } from "@/workspace-creation";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -17,10 +14,6 @@ import { Composer } from "@/composer";
import { ComposerDock } from "@/composer/dock"; import { ComposerDock } from "@/composer/dock";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone"; 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 { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { MenuHeader } from "@/components/headers/menu-header"; import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -52,7 +45,6 @@ import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores
import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspace } from "@/stores/session-store-hooks";
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { import {
useWorkspaceDraftSubmissionStore, useWorkspaceDraftSubmissionStore,
type PendingWorkspaceDraftSetup, type PendingWorkspaceDraftSetup,
@@ -73,19 +65,12 @@ import {
type HostProjectListItem, type HostProjectListItem,
} from "@/projects/host-projects"; } from "@/projects/host-projects";
import { useProjectIcons } from "@/projects/icons"; import { useProjectIcons } from "@/projects/icons";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types"; import type { MessagePayload } from "@/composer/types";
import type { UserComposerAttachment } from "@/attachments/types"; import type { UserComposerAttachment } from "@/attachments/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
import {
getWorkspaceNamingAttachments,
remapDraftCwdToWorkspace,
} from "./new-workspace-fork-context";
import { import {
buildPickerOptionData, buildPickerOptionData,
defaultBasePickerItem, defaultBasePickerItem,
@@ -116,40 +101,8 @@ import {
type ListTerminalsPayload, type ListTerminalsPayload,
upsertCreatedTerminalPayload, upsertCreatedTerminalPayload,
} from "./workspace/terminals/state"; } from "./workspace/terminals/state";
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; 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: { function buildFirstAgentContext(input: {
prompt: string; prompt: string;
attachments: AgentAttachment[]; attachments: AgentAttachment[];
@@ -238,43 +191,6 @@ function normalizeBranchDetails(
*/ */
type SubmitOutcome = "navigated" | "background"; 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 { interface WorkspaceCreationResult {
workspace: ReturnType<typeof normalizeWorkspaceDescriptor>; workspace: ReturnType<typeof normalizeWorkspaceDescriptor>;
agent?: AgentSnapshotPayload; agent?: AgentSnapshotPayload;
@@ -337,67 +253,6 @@ async function createMultiplicityWorkspace(input: {
return { workspace: normalizedWorkspace, agent: payload.agent }; 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: { function buildComposerInitialValues(input: {
initialSetup?: WorkspaceDraftTabSetup | null; initialSetup?: WorkspaceDraftTabSetup | null;
}): CreateAgentInitialValues | undefined { }): CreateAgentInitialValues | undefined {
@@ -412,117 +267,6 @@ function buildComposerInitialValues(input: {
return undefined; 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: { function buildComposerConfig(input: {
serverId: string; serverId: string;
workspaceDirectory: string | null; workspaceDirectory: string | null;
@@ -552,102 +296,6 @@ 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: { function useNewWorkspaceHostSelector(input: {
initialServerId: string; initialServerId: string;
allServerIds: string[]; allServerIds: string[];
@@ -893,7 +541,7 @@ export function NewWorkspaceScreen({
const [creationResult, setCreationResult] = useState< const [creationResult, setCreationResult] = useState<
WorkspaceCreationResult | { workspace: null } WorkspaceCreationResult | { workspace: null }
>({ workspace: null }); >({ workspace: null });
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null); const [pendingAction, setPendingAction] = useState<"empty" | "terminal" | null>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const openAddProjectPicker = useOpenAddProject(); const openAddProjectPicker = useOpenAddProject();
@@ -904,7 +552,6 @@ export function NewWorkspaceScreen({
const projectPickerAnchorRef = useRef<View>(null); const projectPickerAnchorRef = useRef<View>(null);
const isolationPickerAnchorRef = useRef<View>(null); const isolationPickerAnchorRef = useRef<View>(null);
const hostPickerAnchorRef = useRef<View | null>(null); const hostPickerAnchorRef = useRef<View | null>(null);
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
const isStillOnCreateScreen = useNewWorkspaceScreenPresence(); const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
// Launch target: what the composer submits to (chat agent, or a terminal // Launch target: what the composer submits to (chat agent, or a terminal
@@ -987,8 +634,8 @@ export function NewWorkspaceScreen({
const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId); const forkDraftSetup = usePendingWorkspaceDraftSetup(draftId);
const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId); const draftContextScopeKey = useDraftWorkspaceAttachmentScopeKey(draftId);
const visibleDraftContextScopeKeys = useMemo( const visibleDraftContextScopeKeys = useMemo(
() => resolveVisibleDraftContextScopeKeys({ isDraftHandoffActive, draftContextScopeKey }), () => (draftContextScopeKey ? [draftContextScopeKey] : []),
[draftContextScopeKey, isDraftHandoffActive], [draftContextScopeKey],
); );
const chatDraft = useAgentInputDraft({ const chatDraft = useAgentInputDraft({
draftKey, draftKey,
@@ -1006,6 +653,14 @@ export function NewWorkspaceScreen({
); );
const selectedItem = pickerSelection.selectedItem; 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(() => { const handleForgeChangeRequestDetected = useCallback(() => {
dispatchPickerSelection({ type: "pr-detected" }); dispatchPickerSelection({ type: "pr-detected" });
}, []); }, []);
@@ -1034,10 +689,11 @@ export function NewWorkspaceScreen({
cwd: selectedSourceDirectory ?? "", cwd: selectedSourceDirectory ?? "",
}); });
const flushChatDraft = chatDraft.flush;
const worktreeSupport = selectedProject const worktreeSupport = selectedProject
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
: "unsupported"; : "unsupported";
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); const isPending = pendingAction !== null;
const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } = const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
useWorkspaceIsolation({ useWorkspaceIsolation({
supportsMultiplicity: supportsWorkspaceMultiplicity, supportsMultiplicity: supportsWorkspaceMultiplicity,
@@ -1290,9 +946,9 @@ export function NewWorkspaceScreen({
async (payload: MessagePayload) => { async (payload: MessagePayload) => {
try { try {
setErrorMessage(null); setErrorMessage(null);
await composerState?.persistFormPreferences();
await updateFormPreferences({ launchTarget });
if (isEmptyWorkspaceSubmission(payload)) { if (isEmptyWorkspaceSubmission(payload)) {
await composerState?.persistFormPreferences();
await updateFormPreferences({ launchTarget });
setPendingAction("empty"); setPendingAction("empty");
let outcome: SubmitOutcome = "background"; let outcome: SubmitOutcome = "background";
await runCreateEmptyWorkspace({ await runCreateEmptyWorkspace({
@@ -1316,28 +972,38 @@ export function NewWorkspaceScreen({
return; return;
} }
setPendingAction("chat"); // Capture the submitted version after publishing any last-keystroke edit.
const outcome = await runCreateChatAgent({ flushChatDraft();
const attempt = submitWorkspaceChat({
serverId: selectedServerId,
key: creationIdentity.draftId,
returnDraftId: draftId,
project: selectedProject,
sourceDirectory: selectedSourceDirectory,
worktreeSlug: creationIdentity.worktreeSlug,
createsWorktree: !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree",
selectedItem,
payload, payload,
composerState, composerState,
forkDraftSetup,
ensureWorkspace,
serverId: selectedServerId,
clearDraft: chatDraft.clear,
draftKey,
draftId: creationIdentity.draftId,
draftContextScopeKey,
supportsForgeSearch, supportsForgeSearch,
resolveClient: withConnectedClient, forkSetup: forkDraftSetup,
isStillOnCreateScreen, client: withConnectedClient(),
labels: { queryClient,
composerStateRequired: t("newWorkspace.errors.composerStateRequired"), draftId: creationIdentity.draftId,
selectModel: t("newWorkspace.errors.selectModel"), draftKey,
clearDraft: chatDraft.clear,
draftContextScopeKey,
persistPreferences: async () => {
await composerState?.persistFormPreferences();
await updateFormPreferences({ launchTarget });
}, },
}); });
if (outcome === "background") { leaveFullscreen();
setPendingAction(null); navigateToWorkspace({
} serverId: selectedServerId,
workspaceId: attempt.workspaceId,
target: { kind: "agent", agentId: attempt.agentId },
});
} catch (error) { } catch (error) {
const message = toErrorMessage(error); const message = toErrorMessage(error);
setPendingAction(null); setPendingAction(null);
@@ -1346,19 +1012,26 @@ export function NewWorkspaceScreen({
} }
}, },
[ [
draftId,
composerState, composerState,
draftContextScopeKey, draftContextScopeKey,
creationIdentity, creationIdentity,
chatDraft.clear, chatDraft.clear,
flushChatDraft,
draftKey, draftKey,
ensureWorkspace, ensureWorkspace,
forkDraftSetup, forkDraftSetup,
selectedProject,
selectedSourceDirectory,
selectedItem,
supportsWorkspaceMultiplicity,
effectiveIsolation,
queryClient,
isStillOnCreateScreen, isStillOnCreateScreen,
launchTarget, launchTarget,
leaveFullscreen, leaveFullscreen,
selectedServerId, selectedServerId,
supportsForgeSearch, supportsForgeSearch,
t,
toast, toast,
updateFormPreferences, updateFormPreferences,
withConnectedClient, withConnectedClient,
@@ -1,12 +1,8 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { import { captureWorkspaceDraftCleanup } from "./background-handoff";
captureWorkspaceDraftCleanup,
createWorkspaceAgentInBackground,
} from "./background-handoff";
const DRAFT_ID = "draft_1"; const DRAFT_ID = "draft_1";
const DRAFT_KEY = "new-workspace"; const DRAFT_KEY = "new-workspace";
@@ -32,144 +28,96 @@ function captureCleanup() {
}); });
} }
function run(createAgent: () => Promise<unknown>, clearConsumedDraft = captureCleanup()) { function setSetup(cwd: string) {
return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft }); 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" },
},
});
} }
beforeEach(() => { beforeEach(() => {
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} }); useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} });
useCreateFlowStore.setState({ pendingByDraftId: {} });
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } }); useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } });
saveDraft("do the thing"); saveDraft("do the thing");
}); });
describe("createWorkspaceAgentInBackground", () => { describe("captureWorkspaceDraftCleanup", () => {
it("preserves context added to a newer draft without editing its text", async () => { it("clears the consumed draft setup, attachments and input", () => {
await run(async () => { setSetup("/work/repo");
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ addContext();
scopeKey: SCOPE_KEY, const clearConsumedDraft = captureCleanup();
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",
);
});
it("creates one agent and clears the consumed draft setup, attachments and input", async () => { clearConsumedDraft();
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(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({});
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined(); expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined();
expect(draftText()).toBe(""); expect(draftText()).toBe("");
}); });
it("preserves context changed while workspace creation was pending", async () => { it("preserves newer setup without requiring a text edit", () => {
setSetup("/work/repo");
const clearConsumedDraft = captureCleanup(); const clearConsumedDraft = captureCleanup();
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ setSetup("/work/new-repo");
scopeKey: SCOPE_KEY,
attachment: { clearConsumedDraft();
kind: "chat_history",
id: "fork-context", expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe(
attachment: { "/work/new-repo",
type: "text", );
mimeType: "text/plain", expect(draftText()).toBe("do the thing");
contextKind: "chat_history", });
title: "Chat history",
text: "Preserve this context", it("preserves context changed while workspace creation was pending", () => {
}, const clearConsumedDraft = captureCleanup();
source: { serverId: "local", agentId: "agent-1" }, addContext();
},
}); clearConsumedDraft();
await run(async () => undefined, clearConsumedDraft);
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
expect(draftText()).toBe("do the thing"); expect(draftText()).toBe("do the thing");
}); });
it("leaves no pending submission behind, so no draft tab can create a second agent", async () => { it("keeps a prompt typed into the shared draft while creation was in flight", () => {
await run(async () => undefined); const clearConsumedDraft = captureCleanup();
saveDraft("a different idea");
expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({}); clearConsumedDraft();
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"); expect(draftText()).toBe("a different idea");
}); });
it("keeps a newer draft written while workspace creation was pending", async () => {
useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ it("keeps unchanged context when a newer draft owns it", () => {
scopeKey: SCOPE_KEY, addContext();
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(); const clearConsumedDraft = captureCleanup();
saveDraft("a newer workspace idea"); saveDraft("a newer workspace idea");
await run(async () => undefined, clearConsumedDraft);
clearConsumedDraft();
expect(draftText()).toBe("a newer workspace idea"); expect(draftText()).toBe("a newer workspace idea");
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1);
}); });
@@ -34,21 +34,3 @@ export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput):
input.clearDraft("sent"); 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,3 +1,4 @@
import { useCreationTabMenuEntries } from "@/workspace-creation/ui";
import { memo, useCallback, useMemo, useRef, useState } from "react"; import { memo, useCallback, useMemo, useRef, useState } from "react";
import { Keyboard, Pressable, Text, View } from "react-native"; import { Keyboard, Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -202,7 +203,7 @@ function MobileWorkspaceTabOption({
[t], [t],
); );
const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`;
const menuEntries = buildWorkspaceTabMenuEntries({ const rawMenuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile", surface: "mobile",
tab, tab,
index: tabIndex, index: tabIndex,
@@ -221,6 +222,8 @@ function MobileWorkspaceTabOption({
labels: tabMenuLabels, labels: tabMenuLabels,
}); });
const menuEntries = useCreationTabMenuEntries(normalizedServerId, tab.target, rawMenuEntries);
const fallbackLabels = useMemo( const fallbackLabels = useMemo(
() => ({ () => ({
newTab: t("workspace.tabs.actions.newTab"), newTab: t("workspace.tabs.actions.newTab"),
@@ -1,3 +1,4 @@
import { isAgentCreationPending } from "@/workspace-creation";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { type QueryClient } from "@tanstack/react-query"; import { type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -46,6 +47,7 @@ export function useWorkspaceTabRename(
} }
if (tab.target.kind === "agent") { if (tab.target.kind === "agent") {
const { agentId } = tab.target; const { agentId } = tab.target;
if (isAgentCreationPending(normalizedServerId, agentId)) return;
const agent = const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const currentTitle = agent?.title ?? ""; const currentTitle = agent?.title ?? "";
@@ -1,3 +1,4 @@
import { useCreationTabMenuEntries } from "@/workspace-creation/ui";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import React, { import React, {
Fragment, Fragment,
@@ -756,7 +757,8 @@ function TabChip({
onCloseTab: (tabId: string) => Promise<void> | void; onCloseTab: (tabId: string) => Promise<void> | void;
dragHandleProps: DraggableListDragHandleProps | undefined; dragHandleProps: DraggableListDragHandleProps | undefined;
}) { }) {
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; const { closeButtonTestId, contextMenuTestId, menuEntries: rawMenuEntries } = resolvedTab;
const menuEntries = useCreationTabMenuEntries(serverId, tab.target, rawMenuEntries);
const { t } = useTranslation(); const { t } = useTranslation();
const openWith = useFilePanelOpenWithMenu( const openWith = useFilePanelOpenWithMenu(
{ serverId, workspaceId, tabId: tab.tabId }, { serverId, workspaceId, tabId: tab.tabId },
@@ -173,3 +173,20 @@ describe("resolveWorkspaceRouteState", () => {
).toEqual({ kind: "ready" }); ).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,7 +34,9 @@ export function resolveWorkspaceRouteState(input: {
workspace: WorkspaceDescriptor | null; workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean; hasHydratedWorkspaces: boolean;
recovery: WorkspaceRecoveryModel; recovery: WorkspaceRecoveryModel;
pendingCreation?: boolean;
}): WorkspaceRouteState { }): WorkspaceRouteState {
if (input.pendingCreation) return { kind: "ready" };
if (input.workspace) { if (input.workspace) {
if (input.connectionStatus === "online") { if (input.connectionStatus === "online") {
if (input.recovery.kind === "recoverable" && input.recovery.phase === "restoring") { if (input.recovery.kind === "recoverable" && input.recovery.phase === "restoring") {
@@ -1,3 +1,9 @@
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 { useHostFeature } from "@/runtime/host-features";
import { import {
AssistantFileLinkResolverProvider, AssistantFileLinkResolverProvider,
@@ -644,13 +650,16 @@ function buildWorkspaceHeaderCheckoutState(input: {
function deriveWorkspaceHeaderFields(input: { function deriveWorkspaceHeaderFields(input: {
workspace: WorkspaceDescriptor | null; workspace: WorkspaceDescriptor | null;
checkoutState: WorkspaceHeaderCheckoutState; checkoutState: WorkspaceHeaderCheckoutState;
pendingProjectName?: string;
}): WorkspaceHeaderFields { }): WorkspaceHeaderFields {
const renderState = resolveWorkspaceHeaderRenderState(input); const renderState = resolveWorkspaceHeaderRenderState(input);
if (renderState.kind !== "ready") { if (renderState.kind !== "ready") {
return { return {
isWorkspaceHeaderLoading: true, isWorkspaceHeaderLoading: !input.pendingProjectName,
workspaceHeaderTitle: "", workspaceHeaderTitle: input.pendingProjectName
workspaceHeaderSubtitle: "", ? i18n.t("newWorkspace.creation.creatingWorkspace")
: "",
workspaceHeaderSubtitle: input.pendingProjectName ?? "",
isWorkspaceHeaderSubtitleDistinct: false, isWorkspaceHeaderSubtitleDistinct: false,
isGitCheckout: false, isGitCheckout: false,
currentBranchName: null, currentBranchName: null,
@@ -713,6 +722,7 @@ function useResolvedWorkspaceRouteState(input: {
workspace: WorkspaceDescriptor | null; workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean; hasHydratedWorkspaces: boolean;
recovery: WorkspaceRecoveryModel; recovery: WorkspaceRecoveryModel;
pendingCreation: boolean;
}): WorkspaceRouteState { }): WorkspaceRouteState {
const hosts = useHosts(); const hosts = useHosts();
const host = useMemo( const host = useMemo(
@@ -730,6 +740,7 @@ function useResolvedWorkspaceRouteState(input: {
workspace: input.workspace, workspace: input.workspace,
hasHydratedWorkspaces: input.hasHydratedWorkspaces, hasHydratedWorkspaces: input.hasHydratedWorkspaces,
recovery: input.recovery, recovery: input.recovery,
pendingCreation: input.pendingCreation,
}), }),
[ [
hostName, hostName,
@@ -738,6 +749,7 @@ function useResolvedWorkspaceRouteState(input: {
input.workspace, input.workspace,
input.hasHydratedWorkspaces, input.hasHydratedWorkspaces,
input.recovery, input.recovery,
input.pendingCreation,
], ],
); );
} }
@@ -746,7 +758,9 @@ function shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces: boolean, hasHydratedWorkspaces: boolean,
workspace: WorkspaceDescriptor | null, workspace: WorkspaceDescriptor | null,
pendingOpenTarget: WorkspaceTabTarget | null, pendingOpenTarget: WorkspaceTabTarget | null,
canInspectCreation: boolean,
): boolean { ): boolean {
if (!canInspectCreation) return false;
return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null; return pendingOpenTarget?.kind === "agent" && hasHydratedWorkspaces && workspace === null;
} }
@@ -1035,12 +1049,23 @@ function WorkspaceScreenContent({
[workspaceId], [workspaceId],
); );
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId); const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
const creation = useWorkspaceCreation(normalizedServerId, normalizedWorkspaceId);
const creationPresentation = useMemo(
() => workspaceCreationPresentation(workspaceDescriptor, creation),
[workspaceDescriptor, creation],
);
useEffect(() => { useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return; if (
!normalizedServerId ||
!normalizedWorkspaceId ||
workspaceDescriptor ||
(creation && !creation.workspaceObserved)
)
return;
void getHostRuntimeStore() void getHostRuntimeStore()
.prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId) .prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId)
.catch(() => undefined); .catch(() => undefined);
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]); }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor, creation]);
// The focused workspace keeps its script status live even when no sidebar or viewed agent // The focused workspace keeps its script status live even when no sidebar or viewed agent
// timeline demands the directory. Retained, hidden workspaces release it. // timeline demands the directory. Retained, hidden workspaces release it.
useEffect(() => { useEffect(() => {
@@ -1142,7 +1167,7 @@ function WorkspaceScreenContent({
onOpened: revealWorkspaceTab, onOpened: revealWorkspaceTab,
workspaceKey: persistenceKey, workspaceKey: persistenceKey,
hasHydratedWorkspaces, hasHydratedWorkspaces,
workspaceExists: workspaceDescriptor !== null, workspaceExists: creationPresentation.exists,
}); });
const workspaceRecovery = useWorkspaceRecovery({ const workspaceRecovery = useWorkspaceRecovery({
serverId: normalizedServerId, serverId: normalizedServerId,
@@ -1151,6 +1176,7 @@ function WorkspaceScreenContent({
hasHydratedWorkspaces, hasHydratedWorkspaces,
workspaceDescriptor, workspaceDescriptor,
pendingOpenTarget, pendingOpenTarget,
creationPresentation.canInspectRecovery,
), ),
}); });
@@ -1162,12 +1188,17 @@ function WorkspaceScreenContent({
}), }),
[normalizedServerId, normalizedWorkspaceId], [normalizedServerId, normalizedWorkspaceId],
); );
const workspaceAgentVisibility = useStoreWithEqualityFn( const directoryAgentVisibility = useStoreWithEqualityFn(
useSessionStore, useSessionStore,
selectWorkspaceAgentVisibility, selectWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual, workspaceAgentVisibilityEqual,
); );
const workspaceAgentVisibility = useMemo(
() => includeCreatingAgent(directoryAgentVisibility, creation),
[directoryAgentVisibility, creation],
);
const { const {
handleTerminalCreated, handleTerminalCreated,
handleScriptTerminalSelected, handleScriptTerminalSelected,
@@ -1233,6 +1264,7 @@ function WorkspaceScreenContent({
workspace: workspaceDescriptor, workspace: workspaceDescriptor,
hasHydratedWorkspaces, hasHydratedWorkspaces,
recovery: workspaceRecovery.state, recovery: workspaceRecovery.state,
pendingCreation: creationPresentation.workspacePending,
}); });
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({ const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading, isCheckoutStatusLoading,
@@ -1249,6 +1281,7 @@ function WorkspaceScreenContent({
} = deriveWorkspaceHeaderFields({ } = deriveWorkspaceHeaderFields({
workspace: workspaceDescriptor, workspace: workspaceDescriptor,
checkoutState: workspaceHeaderCheckoutState, checkoutState: workspaceHeaderCheckoutState,
pendingProjectName: creationPresentation.projectName,
}); });
const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
@@ -1412,7 +1445,7 @@ function WorkspaceScreenContent({
() => isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(), () => isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(),
[isFocusModeEnabled, isMobile], [isFocusModeEnabled, isMobile],
); );
const visibleAgentIds = useVisibleAgentIds({ const tabAgentIds = useVisibleAgentIds({
layout: workspaceLayout, layout: workspaceLayout,
tabs: uiTabs, tabs: uiTabs,
sheetTab, sheetTab,
@@ -1421,6 +1454,13 @@ function WorkspaceScreenContent({
explorerPaneId: explorerSidebarPaneId, explorerPaneId: explorerSidebarPaneId,
explorerVisible: isExplorerSidebarShowing, explorerVisible: isExplorerSidebarShowing,
}); });
const visibleAgentIds = useMemo(
() =>
tabAgentIds.filter(
(agentId) => !creation || creation.agentId !== agentId || creation.agentObserved,
),
[tabAgentIds, creation],
);
useEffect(() => { useEffect(() => {
for (const agentId of visibleAgentIds) { for (const agentId of visibleAgentIds) {
void getHostRuntimeStore() void getHostRuntimeStore()
@@ -1834,6 +1874,10 @@ function WorkspaceScreenContent({
const launchWorkspaceTab = useCallback( const launchWorkspaceTab = useCallback(
(selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => { (selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => {
if (!workspaceDirectory) {
toast.error(t("newWorkspace.creation.workspacePending"));
return;
}
if (!persistenceKey) { if (!persistenceKey) {
return; return;
} }
@@ -1865,7 +1909,15 @@ function WorkspaceScreenContent({
const { browserId } = createWorkspaceBrowser(); const { browserId } = createWorkspaceBrowser();
openTarget({ kind: "browser", browserId }); openTarget({ kind: "browser", browserId });
}, },
[createTerminal, createWorkspaceTab, persistenceKey, replaceWorkspaceTabTarget], [
createTerminal,
createWorkspaceTab,
persistenceKey,
replaceWorkspaceTabTarget,
workspaceDirectory,
t,
toast,
],
); );
const handleOpenUrlInBrowserTab = useCallback( const handleOpenUrlInBrowserTab = useCallback(
@@ -1951,6 +2003,10 @@ function WorkspaceScreenContent({
const handleCloseAgentTab = useCallback( const handleCloseAgentTab = useCallback(
async (input: { tabId: string; agentId: string }) => { async (input: { tabId: string; agentId: string }) => {
const { tabId, agentId } = input; const { tabId, agentId } = input;
if (isAgentCreationPending(normalizedServerId, agentId)) {
closeWorkspaceTabWithCleanup({ tabId, target: { kind: "agent", agentId } });
return;
}
await closeTab(tabId, async () => { await closeTab(tabId, async () => {
if (!normalizedServerId) { if (!normalizedServerId) {
return; return;
@@ -2164,6 +2220,10 @@ function WorkspaceScreenContent({
const handleReloadAgent = useCallback( const handleReloadAgent = useCallback(
async (agentId: string) => { async (agentId: string) => {
if (isAgentCreationPending(normalizedServerId, agentId)) {
toast.show(t("newWorkspace.creation.agentPending"));
return;
}
if (!client || !isConnected) { if (!client || !isConnected) {
toast.error(t("workspace.terminal.hostDisconnected")); toast.error(t("workspace.terminal.hostDisconnected"));
return; return;
@@ -2249,6 +2309,7 @@ function WorkspaceScreenContent({
} }
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => { const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
if (isAgentCreationPending(normalizedServerId, agentId)) return "layout-only";
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId); const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive"; return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
}); });
@@ -2280,6 +2341,7 @@ function WorkspaceScreenContent({
groups, groups,
closeTab, closeTab,
closeLayoutOnlyAgent: async (agentId) => { closeLayoutOnlyAgent: async (agentId) => {
if (isAgentCreationPending(normalizedServerId, agentId)) return;
if (!client) { if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable")); throw new Error(t("common.errors.daemonClientUnavailable"));
} }
+8
View File
@@ -352,6 +352,14 @@ export function selectAgentSentMessages(
return messages; 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( export function selectAgentTurnPresentation(
session: SessionState | undefined, session: SessionState | undefined,
agentId: string, agentId: string,
@@ -45,27 +45,3 @@ test("the production app consumes a draft once and leaves other drafts pending",
expect(store.getState().consumePending(submission)).toBeNull(); expect(store.getState().consumePending(submission)).toBeNull();
expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]); 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,16 +1,9 @@
import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { create } from "zustand"; import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment } from "@/attachments/types";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
export interface PendingWorkspaceDraftSubmission { export interface PendingWorkspaceDraftSubmission {
/** Already-running creation. Mounting the draft only observes its result. */
agentCreation?: {
result: Promise<AgentSnapshotPayload>;
retry: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>;
};
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
draftId: string; draftId: string;
@@ -35,7 +28,6 @@ export interface PendingWorkspaceDraftSetup {
interface WorkspaceDraftSubmissionState { interface WorkspaceDraftSubmissionState {
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>; pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
setupByDraftId: Record<string, PendingWorkspaceDraftSetup>; setupByDraftId: Record<string, PendingWorkspaceDraftSetup>;
creationByDraftId: Record<string, NonNullable<PendingWorkspaceDraftSubmission["agentCreation"]>>;
setPending: (submission: PendingWorkspaceDraftSubmission) => void; setPending: (submission: PendingWorkspaceDraftSubmission) => void;
setDraftSetup: (input: { setDraftSetup: (input: {
draftId: string; draftId: string;
@@ -69,12 +61,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
(set, get) => ({ (set, get) => ({
pendingByDraftId: {}, pendingByDraftId: {},
setupByDraftId: {}, setupByDraftId: {},
creationByDraftId: {},
setPending: (submission) => setPending: (submission) =>
set((state) => ({ set((state) => ({
creationByDraftId: submission.agentCreation
? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation }
: state.creationByDraftId,
pendingByDraftId: { pendingByDraftId: {
...state.pendingByDraftId, ...state.pendingByDraftId,
[submission.draftId]: submission, [submission.draftId]: submission,
@@ -94,10 +82,8 @@ export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionS
const normalizedDraftId = normalizeDraftId(draftId); const normalizedDraftId = normalizeDraftId(draftId);
if (!normalizedDraftId) return; if (!normalizedDraftId) return;
set((state) => { set((state) => {
const creationByDraftId = { ...state.creationByDraftId };
delete creationByDraftId[normalizedDraftId];
const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId; const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId;
return { setupByDraftId, creationByDraftId }; return { setupByDraftId };
}); });
}, },
consumePending: (input) => { consumePending: (input) => {
@@ -271,7 +271,6 @@ export interface WorkspaceTabSnapshot {
terminalsHydrated: boolean; terminalsHydrated: boolean;
activeAgentIds: Iterable<string>; activeAgentIds: Iterable<string>;
autoOpenAgentIds: Iterable<string>; autoOpenAgentIds: Iterable<string>;
foreignAgentIds?: Iterable<string>;
knownTerminalIds?: Iterable<string>; knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>; standaloneTerminalIds: Iterable<string>;
hasActivePendingTerminalCreate?: boolean; hasActivePendingTerminalCreate?: boolean;
@@ -2444,16 +2443,13 @@ export function reconcileWorkspaceTabs(
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []); // An explicit open owns the tab until the agent joins the active directory.
// Explicit opens survive absent directory entries, but not known foreign ownership. // From then on it follows the normal server archive lifecycle. Detail/cache
// Once an agent joins this workspace's active directory, normal archive rules apply. // hydration never decides whether the user's target is allowed to stay open.
const pinnedAgentIds = new Set( const pinnedAgentIds = new Set(
[...(state.pinnedAgentIds ?? [])].filter((agentId) => { [...(state.pinnedAgentIds ?? [])].filter(
if (!snapshot.agentsHydrated) { (agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId),
return true; ),
}
return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId);
}),
); );
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const knownTerminalIds = snapshot.knownTerminalIds const knownTerminalIds = snapshot.knownTerminalIds
@@ -0,0 +1,9 @@
export {
useWorkspaceCreations,
useWorkspaceCreation,
useAgentCreation,
isAgentCreationPending,
isAgentReadyForPrompts,
useIsAgentReadyForPrompts,
} from "./runtime";
export { submitWorkspaceChat, takeWorkspaceCreationFailure } from "./submit";
@@ -0,0 +1,12 @@
/** 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;
}
@@ -0,0 +1,342 @@
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);
});
@@ -0,0 +1,159 @@
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);
});
});
@@ -0,0 +1 @@
export { randomUUID } from "expo-crypto";
@@ -0,0 +1,3 @@
export function randomUUID(): string {
return crypto.randomUUID();
}
@@ -0,0 +1,238 @@
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(),
};
}
@@ -0,0 +1,113 @@
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,
};
}
@@ -0,0 +1,146 @@
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;
}
@@ -0,0 +1,292 @@
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;
}
@@ -0,0 +1,56 @@
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,3 +1,5 @@
import { useWorkspaceCreations } from "@/workspace-creation";
import type { CreationAttempt } from "@/workspace-creation/model";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store"; import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
@@ -13,12 +15,21 @@ export function deriveEffectiveWorkspaceStatus(input: {
serverId: string; serverId: string;
workspace: WorkspaceDescriptor; workspace: WorkspaceDescriptor;
pendingCreateAttempts?: Record<string, PendingCreateAttempt>; pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
workspaceCreations?: readonly CreationAttempt[];
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>; workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
}): EffectiveWorkspaceStatus { }): EffectiveWorkspaceStatus {
if (input.workspace.status !== "done") { if (input.workspace.status !== "done") {
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; 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({ const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
serverId: input.serverId, serverId: input.serverId,
workspaceId: input.workspace.id, workspaceId: input.workspace.id,
@@ -55,6 +66,7 @@ function getPendingInitialAgentCreateStartedAt(input: {
} }
export function useWorkspaceStatus(serverId: string, workspaceId: string) { export function useWorkspaceStatus(serverId: string, workspaceId: string) {
const workspaceCreations = useWorkspaceCreations();
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId); const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
return useSessionStore( return useSessionStore(
useShallow((state) => { useShallow((state) => {
@@ -67,6 +79,7 @@ export function useWorkspaceStatus(serverId: string, workspaceId: string) {
serverId, serverId,
workspace, workspace,
pendingCreateAttempts, pendingCreateAttempts,
workspaceCreations,
workspaceAgentActivity: session.workspaceAgentActivity, workspaceAgentActivity: session.workspaceAgentActivity,
}).status; }).status;
}), }),
@@ -1,7 +1,6 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { import {
buildWorkspaceTabSnapshot, buildWorkspaceTabSnapshot,
createWorkspaceAgentVisibilitySelector, createWorkspaceAgentVisibilitySelector,
@@ -64,118 +63,6 @@ function makeAgent(input: {
const WORKSPACE_ID = "ws-1"; const WORKSPACE_ID = "ws-1";
describe("workspace agent visibility", () => { 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", () => { it("keeps subagents active while excluding them from auto-open", () => {
const parent = makeAgent({ const parent = makeAgent({
id: "parent-agent", id: "parent-agent",
@@ -199,7 +86,6 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
expect(result.foreignAgentIds).toEqual(new Set());
}); });
it("excludes archived subagents from active and auto-open", () => { it("excludes archived subagents from active and auto-open", () => {
@@ -409,7 +295,6 @@ describe("workspace agent visibility", () => {
}); });
expect(result.activeAgentIds).toEqual(new Set<string>()); expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"]));
}); });
it("excludes agents without a workspaceId", () => { it("excludes agents without a workspaceId", () => {
@@ -423,14 +308,12 @@ describe("workspace agent visibility", () => {
}); });
expect(result.activeAgentIds).toEqual(new Set<string>()); 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", () => { it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
const agentVisibility = { const agentVisibility = {
activeAgentIds: new Set(["active-agent"]), activeAgentIds: new Set(["active-agent"]),
autoOpenAgentIds: new Set(["root-agent"]), autoOpenAgentIds: new Set(["root-agent"]),
foreignAgentIds: new Set(["foreign-agent"]),
}; };
expect( expect(
@@ -448,7 +331,6 @@ describe("workspace agent visibility", () => {
terminalsHydrated: true, terminalsHydrated: true,
activeAgentIds: agentVisibility.activeAgentIds, activeAgentIds: agentVisibility.activeAgentIds,
autoOpenAgentIds: agentVisibility.autoOpenAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
foreignAgentIds: agentVisibility.foreignAgentIds,
knownTerminalIds: ["terminal-1", "script-terminal"], knownTerminalIds: ["terminal-1", "script-terminal"],
standaloneTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"],
hasActivePendingTerminalCreate: false, hasActivePendingTerminalCreate: false,
@@ -587,24 +469,6 @@ describe("workspace agent visibility", () => {
expect(after.activeAgentIds).toEqual(new Set([child.id])); expect(after.activeAgentIds).toEqual(new Set([child.id]));
expect(after.autoOpenAgentIds).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", () => { describe("workspaceAgentVisibilityEqual", () => {
@@ -612,12 +476,10 @@ describe("workspace agent visibility", () => {
const a = { const a = {
activeAgentIds: new Set(["a", "b"]), activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
}; };
const b = { const b = {
activeAgentIds: new Set(["a", "b"]), activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
}; };
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
}); });
@@ -626,12 +488,10 @@ describe("workspace agent visibility", () => {
const a = { const a = {
activeAgentIds: new Set(["a"]), activeAgentIds: new Set(["a"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
}; };
const b = { const b = {
activeAgentIds: new Set(["b"]), activeAgentIds: new Set(["b"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
}; };
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
}); });
@@ -640,12 +500,10 @@ describe("workspace agent visibility", () => {
const a = { const a = {
activeAgentIds: new Set(["a", "b"]), activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
}; };
const b = { const b = {
activeAgentIds: new Set(["a", "b"]), activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["b"]),
foreignAgentIds: new Set<string>(),
}; };
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
}); });
@@ -654,12 +512,10 @@ describe("workspace agent visibility", () => {
const a = { const a = {
activeAgentIds: new Set<string>(), activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(), autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
}; };
const b = { const b = {
activeAgentIds: new Set<string>(), activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(), autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
}; };
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
}); });
@@ -6,7 +6,10 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export interface WorkspaceAgentVisibility { export interface WorkspaceAgentVisibility {
activeAgentIds: Set<string>; activeAgentIds: Set<string>;
autoOpenAgentIds: Set<string>; autoOpenAgentIds: Set<string>;
foreignAgentIds: Set<string>; }
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
} }
export function deriveWorkspaceAgentVisibility(input: { export function deriveWorkspaceAgentVisibility(input: {
@@ -16,23 +19,17 @@ export function deriveWorkspaceAgentVisibility(input: {
}): WorkspaceAgentVisibility { }): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails } = input; const { sessionAgents, agentDetails } = input;
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!workspaceId) { if ((!sessionAgents && !agentDetails) || !workspaceId) {
return { return {
activeAgentIds: new Set<string>(), activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(), autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
}; };
} }
const activeAgentIds = new Set<string>(); const activeAgentIds = new Set<string>();
const autoOpenAgentIds = new Set<string>(); const autoOpenAgentIds = new Set<string>();
const foreignAgentIds = new Set<string>();
for (const agent of sessionAgents?.values() ?? []) { for (const agent of sessionAgents?.values() ?? []) {
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId); if (!agentBelongsToWorkspace(agent, workspaceId)) {
if (ownerId !== workspaceId) {
if (ownerId) {
foreignAgentIds.add(agent.id);
}
continue; continue;
} }
if (!agent.archivedAt) { if (!agent.archivedAt) {
@@ -45,16 +42,7 @@ export function deriveWorkspaceAgentVisibility(input: {
} }
} }
} }
for (const agent of agentDetails?.values() ?? []) { return { activeAgentIds, autoOpenAgentIds };
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 { interface AgentMapsSnapshot {
@@ -111,7 +99,6 @@ export function buildWorkspaceTabSnapshot(input: {
terminalsHydrated: input.terminalsHydrated, terminalsHydrated: input.terminalsHydrated,
activeAgentIds: input.agentVisibility.activeAgentIds, activeAgentIds: input.agentVisibility.activeAgentIds,
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
foreignAgentIds: input.agentVisibility.foreignAgentIds,
knownTerminalIds: input.knownTerminalIds, knownTerminalIds: input.knownTerminalIds,
standaloneTerminalIds: input.standaloneTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds,
hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate,
@@ -126,8 +113,7 @@ export function workspaceAgentVisibilityEqual(
return ( return (
a === b || a === b ||
(setsEqual(a.activeAgentIds, b.activeAgentIds) && (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", "name": "@getpaseo/cli",
"version": "0.13.2", "version": "0.13.1",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"bin": { "bin": {
"paseo": "bin/paseo" "paseo": "bin/paseo"
@@ -28,9 +28,9 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.2", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"@getpaseo/server": "0.13.2", "@getpaseo/server": "0.13.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/client", "name": "@getpaseo/client",
"version": "0.13.2", "version": "0.13.1",
"description": "Paseo client SDK package", "description": "Paseo client SDK package",
"files": [ "files": [
"dist", "dist",
@@ -47,8 +47,8 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.2", "@getpaseo/relay": "0.13.1",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.13.2", "version": "0.13.1",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh", "homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.13.2", "version": "0.13.1",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"keywords": [ "keywords": [
"ExpoTwoWayAudio", "ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.13.2", "version": "0.13.1",
"files": [ "files": [
"dist", "dist",
"!dist/**/*.map", "!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/plugin", "name": "@getpaseo/plugin",
"version": "0.13.2", "version": "0.13.1",
"description": "Paseo plugin SDK package", "description": "Paseo plugin SDK package",
"files": [ "files": [
"dist", "dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@getpaseo/client": "0.13.2", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"@types/node": "^20.9.0", "@types/node": "^20.9.0",
"@types/react": "~19.2.0", "@types/react": "~19.2.0",
"react": "19.2.3", "react": "19.2.3",
@@ -83,8 +83,8 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"peerDependencies": { "peerDependencies": {
"@getpaseo/client": "0.13.2", "@getpaseo/client": "0.13.1",
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"react": ">=19.1.0 <20", "react": ">=19.1.0 <20",
"react-native": ">=0.81.5", "react-native": ">=0.81.5",
"zod": "^4.4.3" "zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/protocol", "name": "@getpaseo/protocol",
"version": "0.13.2", "version": "0.13.1",
"description": "Paseo shared protocol schemas and wire types", "description": "Paseo shared protocol schemas and wire types",
"files": [ "files": [
"dist", "dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.13.2", "version": "0.13.1",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"files": [ "files": [
"dist", "dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.13.2", "version": "0.13.1",
"description": "Paseo backend server", "description": "Paseo backend server",
"files": [ "files": [
"dist/server", "dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2", "@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.2", "@getpaseo/client": "0.13.1",
"@getpaseo/highlight": "0.13.2", "@getpaseo/highlight": "0.13.1",
"@getpaseo/plugin": "0.13.2", "@getpaseo/plugin": "0.13.1",
"@getpaseo/protocol": "0.13.2", "@getpaseo/protocol": "0.13.1",
"@getpaseo/relay": "0.13.2", "@getpaseo/relay": "0.13.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46", "@opencode-ai/sdk": "1.14.46",
@@ -1,3 +1,4 @@
import { resolve } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { resolveCreateAgentIntent } from "./intent.js"; import { resolveCreateAgentIntent } from "./intent.js";
@@ -23,6 +24,38 @@ 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 () => { it("defaults an agent caller to its workspace without creating one", async () => {
let createCount = 0; let createCount = 0;
const intent = await resolveCreateAgentIntent({ const intent = await resolveCreateAgentIntent({
@@ -1,3 +1,4 @@
import { isAbsolute, relative, resolve, sep } from "node:path";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
export interface CreateAgentCaller { export interface CreateAgentCaller {
@@ -20,6 +21,9 @@ export interface CreateAgentIntent {
export async function resolveCreateAgentIntent(input: { export async function resolveCreateAgentIntent(input: {
explicitWorkspaceId?: string; 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; caller: CreateAgentCaller | null;
labels?: Record<string, string>; labels?: Record<string, string>;
childAgentDefaultLabels?: Record<string, string>; childAgentDefaultLabels?: Record<string, string>;
@@ -47,12 +51,27 @@ export async function resolveCreateAgentIntent(input: {
async function resolvePlacement(input: { async function resolvePlacement(input: {
explicitWorkspaceId?: string; explicitWorkspaceId?: string;
workspaceRelativeCwd?: string;
caller: CreateAgentCaller | null; caller: CreateAgentCaller | null;
resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>; resolveWorkspace: (workspaceId: string) => Promise<CreateAgentPlacement>;
createWorkspace: () => Promise<CreateAgentPlacement>; createWorkspace: () => Promise<CreateAgentPlacement>;
}): Promise<CreateAgentPlacement> { }): Promise<CreateAgentPlacement> {
if (input.explicitWorkspaceId) { if (input.explicitWorkspaceId) {
return input.resolveWorkspace(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");
} }
if (input.caller) { if (input.caller) {
if (!input.caller.workspaceId) { if (!input.caller.workspaceId) {
@@ -811,46 +811,6 @@ describe("ACPAgentSession terminal tools", () => {
truncated: false, 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", () => { describe("mapACPUsage", () => {
@@ -4093,16 +4093,10 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
if (!limit) { if (!limit) {
return; return;
} }
const output = Buffer.from(entry.output, "utf8"); while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) {
if (output.length <= limit) { entry.output = entry.output.slice(1);
return; entry.truncated = true;
} }
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 { function readRecord(value: unknown): Record<string, unknown> | null {
@@ -3063,81 +3063,6 @@ 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 () => { test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => {
const { pi, session } = await createSession(); const { pi, session } = await createSession();
const fakeSession = pi.latestSession(); const fakeSession = pi.latestSession();
@@ -1189,7 +1189,6 @@ export class PiRpcAgentSession implements AgentSession {
private readonly contextUserEntries: PiCapturedEntry[] = []; private readonly contextUserEntries: PiCapturedEntry[] = [];
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>(); private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>(); private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private compactionPromise: Promise<void> | null = null;
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
private outOfBandCompactionStarted = false; private outOfBandCompactionStarted = false;
private outOfBandCompactionCompleted = false; private outOfBandCompactionCompleted = false;
@@ -1297,15 +1296,6 @@ export class PiRpcAgentSession implements AgentSession {
void (async () => { void (async () => {
try { 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); const ack = await this.runtimeSession.prompt(payload.text, payload.images);
this.activePromptRequestId = ack.requestId ?? null; this.activePromptRequestId = ack.requestId ?? null;
const correlatedResult = ack.requestId const correlatedResult = ack.requestId
@@ -1814,8 +1804,7 @@ export class PiRpcAgentSession implements AgentSession {
this.outOfBandCompactionStarted = false; this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false; this.outOfBandCompactionCompleted = false;
try { try {
this.compactionPromise = this.runtimeSession.compact(customInstructions); await this.runtimeSession.compact(customInstructions);
await this.compactionPromise;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if ( if (
@@ -1841,7 +1830,6 @@ export class PiRpcAgentSession implements AgentSession {
}, },
}); });
} finally { } finally {
this.compactionPromise = null;
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) { if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
this.outOfBandCompactionEmit = null; this.outOfBandCompactionEmit = null;
this.outOfBandCompactionStarted = false; this.outOfBandCompactionStarted = false;
@@ -108,7 +108,6 @@ export class FakePiSession implements PiRuntimeSession {
steerError: Error | null = null; steerError: Error | null = null;
readonly controlRequests: string[] = []; readonly controlRequests: string[] = [];
clearQueueError: Error | null = null; clearQueueError: Error | null = null;
clearQueueCompletion: Promise<void> | null = null;
readonly compactRequests: Array<{ customInstructions?: string }> = []; readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = []; readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = []; readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
@@ -141,7 +140,6 @@ export class FakePiSession implements PiRuntimeSession {
subagentSubscriptionError: Error | null = null; subagentSubscriptionError: Error | null = null;
setSessionNameError: Error | null = null; setSessionNameError: Error | null = null;
compactError: Error | null = null; compactError: Error | null = null;
compactCompletion: Promise<void> | null = null;
emitCompactEnd = true; emitCompactEnd = true;
getStateError: Error | null = null; getStateError: Error | null = null;
getSessionStatsError: Error | null = null; getSessionStatsError: Error | null = null;
@@ -235,7 +233,6 @@ export class FakePiSession implements PiRuntimeSession {
async clearQueue(): Promise<void> { async clearQueue(): Promise<void> {
this.controlRequests.push("clear_queue"); this.controlRequests.push("clear_queue");
await this.clearQueueCompletion;
if (this.clearQueueError) { if (this.clearQueueError) {
throw this.clearQueueError; throw this.clearQueueError;
} }
@@ -244,7 +241,6 @@ export class FakePiSession implements PiRuntimeSession {
async compact(customInstructions?: string): Promise<void> { async compact(customInstructions?: string): Promise<void> {
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
this.emit({ type: "compaction_start", reason: "manual" }); this.emit({ type: "compaction_start", reason: "manual" });
await this.compactCompletion;
if (this.emitCompactEnd) { if (this.emitCompactEnd) {
this.emit({ type: "compaction_end", reason: "manual" }); this.emit({ type: "compaction_end", reason: "manual" });
} }
@@ -1,147 +0,0 @@
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"]);
});
+3 -54
View File
@@ -1,26 +1,10 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; 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( export async function writeFileAtomic(
filePath: string, filePath: string,
data: string | NodeJS.ArrayBufferView, data: string | NodeJS.ArrayBufferView,
operations: AtomicFileRuntime = runtime,
): Promise<void> { ): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = path.join( const tempPath = path.join(
@@ -29,48 +13,13 @@ export async function writeFileAtomic(
); );
try { try {
await fs.writeFile(tempPath, data, "utf8"); await fs.writeFile(tempPath, data, "utf8");
await replaceFile(tempPath, filePath, operations); await fs.rename(tempPath, filePath);
} catch (error) { } catch (error) {
await fs.rm(tempPath, { force: true }); await fs.rm(tempPath, { force: true });
throw error; throw error;
} }
} }
async function replaceFile( export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
source: string, await writeFileAtomic(filePath, JSON.stringify(value, null, 2));
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