mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 19:22:51 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c708c263c8 | ||
|
|
0b55d63ab1 | ||
|
|
3a3ccd5385 |
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -1 +1 @@
|
|||||||
sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE=
|
sha256-SKL7TMIuuV3imTIaQc66UDf3n6V3FB0nGSBucJ6rlA0=
|
||||||
|
|||||||
Generated
+29
-28
@@ -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
@@ -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,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1339,6 +1339,15 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
newWorkspace: {
|
newWorkspace: {
|
||||||
|
creation: {
|
||||||
|
creatingWorkspace: "جارٍ إنشاء مساحة العمل…",
|
||||||
|
creatingLabel: "جارٍ إنشاء مساحة العمل",
|
||||||
|
updateHost: "حدّث هذا المضيف لإنشاء مساحة عمل تتضمن وكيلاً.",
|
||||||
|
chooseProjectHost: "اختر مشروعًا ومضيفًا",
|
||||||
|
projectUnavailable: "المشروع غير متاح على المضيف المحدد",
|
||||||
|
workspacePending: "لا يزال إنشاء مساحة العمل جاريًا.",
|
||||||
|
agentPending: "لا يزال إنشاء الوكيل جاريًا.",
|
||||||
|
},
|
||||||
title: "مساحة عمل جديدة",
|
title: "مساحة عمل جديدة",
|
||||||
create: "يخلق",
|
create: "يخلق",
|
||||||
isolation: {
|
isolation: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1353,6 +1353,15 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
newWorkspace: {
|
newWorkspace: {
|
||||||
|
creation: {
|
||||||
|
creatingWorkspace: "ワークスペースを作成中…",
|
||||||
|
creatingLabel: "ワークスペースを作成中",
|
||||||
|
updateHost: "エージェント付きのワークスペースを作成するには、このホストを更新してください。",
|
||||||
|
chooseProjectHost: "プロジェクトとホストを選択してください",
|
||||||
|
projectUnavailable: "選択したホストでプロジェクトを利用できません",
|
||||||
|
workspacePending: "ワークスペースはまだ作成中です。",
|
||||||
|
agentPending: "エージェントはまだ作成中です。",
|
||||||
|
},
|
||||||
title: "新しいワークスペース",
|
title: "新しいワークスペース",
|
||||||
create: "作成",
|
create: "作成",
|
||||||
isolation: {
|
isolation: {
|
||||||
|
|||||||
@@ -1346,6 +1346,15 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
newWorkspace: {
|
newWorkspace: {
|
||||||
|
creation: {
|
||||||
|
creatingWorkspace: "워크스페이스 생성 중…",
|
||||||
|
creatingLabel: "워크스페이스 생성 중",
|
||||||
|
updateHost: "에이전트가 있는 워크스페이스를 만들려면 이 호스트를 업데이트하세요.",
|
||||||
|
chooseProjectHost: "프로젝트와 호스트를 선택하세요",
|
||||||
|
projectUnavailable: "선택한 호스트에서 프로젝트를 사용할 수 없습니다",
|
||||||
|
workspacePending: "워크스페이스가 아직 생성 중입니다.",
|
||||||
|
agentPending: "에이전트가 아직 생성 중입니다.",
|
||||||
|
},
|
||||||
title: "새 워크스페이스",
|
title: "새 워크스페이스",
|
||||||
create: "생성",
|
create: "생성",
|
||||||
isolation: {
|
isolation: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1357,6 +1357,15 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
newWorkspace: {
|
newWorkspace: {
|
||||||
|
creation: {
|
||||||
|
creatingWorkspace: "Создание рабочей области…",
|
||||||
|
creatingLabel: "Создание рабочей области",
|
||||||
|
updateHost: "Обновите этот хост, чтобы создать рабочую область с агентом.",
|
||||||
|
chooseProjectHost: "Выберите проект и хост",
|
||||||
|
projectUnavailable: "Проект недоступен на выбранном хосте",
|
||||||
|
workspacePending: "Рабочая область ещё создаётся.",
|
||||||
|
agentPending: "Агент ещё создаётся.",
|
||||||
|
},
|
||||||
title: "Новое рабочее пространство",
|
title: "Новое рабочее пространство",
|
||||||
create: "Создать",
|
create: "Создать",
|
||||||
isolation: {
|
isolation: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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,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,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",
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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"]);
|
|
||||||
});
|
|
||||||
@@ -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
Reference in New Issue
Block a user