mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56f4af0436 | ||
|
|
9072bce0c3 |
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.1 - 2026-10-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt))
|
||||
|
||||
## 0.13.0 - 2026-10-24
|
||||
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.13.1 - 2026-10-26
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献)
|
||||
|
||||
## 0.13.0 - 2026-10-24
|
||||
|
||||
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
||||
|
||||
+5
-44
@@ -340,50 +340,11 @@ Example: adding a new enum value
|
||||
|
||||
### Creation ownership
|
||||
|
||||
Creation execution is owned by the daemon across socket lifetimes. A workspace request
|
||||
can include its initial agent and prompt. The app's `workspace-creation/` module owns
|
||||
chat submission presentation outside React: it reserves final IDs and a message identity,
|
||||
then opens the agent target before asynchronous preparation or a daemon response.
|
||||
The New workspace chat entry requires `creationLifecycle`; empty workspaces, terminal
|
||||
launches, and existing-workspace agent drafts retain their own entry points.
|
||||
|
||||
Pending presentation never enters the directory replica as a fabricated resource.
|
||||
Routes, tabs, and the sidebar retain it until the corresponding authoritative resource
|
||||
has been observed. Files, terminals, scripts, and agent timeline initialization wait for
|
||||
real resources. The creating agent opens in the normal agent panel and composer, showing
|
||||
its submitted configuration. Until the host publishes the agent, its destination cwd is
|
||||
unknown and its controls are read-only. The host publishes the agent before it starts the
|
||||
initial prompt, so prompts wait in the agent's queue until creation completes
|
||||
(`isAgentReadyForPrompts`). `composer/actions.ts` applies that to every prompt path: send
|
||||
and review feedback queue behind earlier prompts, and send now and queue drain leave the
|
||||
queue untouched. The queue drains on adoption or when the initial turn ends. The first
|
||||
message uses composer submission reconciliation; creation never sends it again when the
|
||||
panel mounts. Completion does not navigate, and draft cleanup checks the captured version
|
||||
so it cannot erase newer input.
|
||||
|
||||
A failed creation goes to the existing owner of whatever the host did not create, using
|
||||
the receipt's resources and `failedStage`. Creation has no failure UI of its own:
|
||||
|
||||
- No workspace: the app returns to New workspace with the submitted draft and checkout
|
||||
selection, and shows the error the way that screen shows its own submit errors.
|
||||
- Workspace but no agent: the agent tab becomes an existing-workspace agent draft holding
|
||||
the prompt, in the abandoned create-flow state. Its error and Send retry are that draft's
|
||||
own; the retry creates the agent in the kept workspace.
|
||||
- An agent that exists or may exist, including a failed first prompt: creation completes
|
||||
and the agent tab keeps the agent's own timeline and turn error. Turning it into a draft
|
||||
would create a second agent on Send.
|
||||
|
||||
Queued follow-ups and unsent composer text belong to a composer identity, so they move
|
||||
with the creation: from the form to the creating agent, back to the form or the agent draft
|
||||
on failure, and to the created agent after retry (`transferComposerFollowUps`). The initial
|
||||
prompt stays the form's or draft's input. The agent draft also carries the submitted
|
||||
setup and context attachments. The host ignores `config.cwd` when a create request names a
|
||||
workspace, so a draft's retry starts at the workspace root, not a nested fork cwd.
|
||||
|
||||
Adopted successful attempts release their payloads; a bounded cache of compact receipts
|
||||
absorbs duplicate commands from retained forms. The SDK owns reconnect observation; the
|
||||
app has no resend loop. Pending views survive navigation and pane remount, not app
|
||||
process death. Resource reservations are identities, not ready workspace records.
|
||||
Creation is owned by the daemon across socket lifetimes. A workspace request can include
|
||||
its initial agent and prompt. Workspace readiness is published before provider startup;
|
||||
the app keeps its existing disk-ready navigation and observes the remaining creation.
|
||||
Callbacks do not advance the workflow. Resource reservations in acknowledgement are
|
||||
identities, not ready workspace records.
|
||||
|
||||
The creation journal (`server/creation/`) owns identity and execution for both legacy and
|
||||
modern creation RPCs. Requesting progress never selects a different journal. Existing
|
||||
|
||||
+41
-20
@@ -221,6 +221,12 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey?: string,
|
||||
baseUrl?: string,
|
||||
stt?: { apiKey?: string, baseUrl?: string },
|
||||
tts?: { apiKey?: string, baseUrl?: string }
|
||||
},
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -238,8 +244,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that
|
||||
pluginsEnabled: boolean,
|
||||
plugins: Record<pluginId, { source: "directory", path: string, enabled?: boolean }>,
|
||||
features: {
|
||||
dictation: { enabled, stt: SpeechSttConfig },
|
||||
voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig }
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
@@ -347,27 +353,42 @@ Environment variables override `config.json`:
|
||||
after changing `config.json`. Environment changes require a daemon restart; the launch environment
|
||||
remains authoritative during reload.
|
||||
|
||||
### Speech configuration
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
Each use site owns its provider settings: `features.dictation.stt`,
|
||||
`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict
|
||||
`local` or `openai` variant. Both STT variants carry `language`; normalization preserves
|
||||
it regardless of provider. `providers.local.modelsDir` remains shared storage.
|
||||
See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence.
|
||||
Set these to select OpenAI instead of local speech:
|
||||
|
||||
On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs
|
||||
into OpenAI sites that lack their own values, using endpoint settings before shared
|
||||
settings. A provider-less site becomes local; provider names are lower-cased, and
|
||||
fields ignored by the selected speech provider are discarded. The next save writes only the
|
||||
inline shape. A legacy key with no site to receive it is discarded with a warning
|
||||
that names its path, without logging its value.
|
||||
| Env var | Applies to |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
`speech-settings.ts` owns mutable config reads and patches. The wire uses the same
|
||||
`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config)
|
||||
and report effective env sources in `envOverrides`. A site patch that changes
|
||||
`provider` replaces the entire object. The same or omitted `provider` merges fields
|
||||
into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and
|
||||
options from the previous variant.
|
||||
OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"stt": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://stt.example.com/v1"
|
||||
},
|
||||
"tts": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
- dictation STT: `/v1/audio/transcriptions`
|
||||
- voice mode STT: `/v1/audio/transcriptions`
|
||||
- voice mode TTS: `/v1/audio/speech`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-3
@@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Text fields take `xs`, `sm` and `md`. Thin chrome such as the file toolbar uses `xs`. A settings row sizes the controls in its trailing slot itself, `sm` in a card and `xs` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and corner radius. Its segments run one padding step tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
@@ -126,8 +126,6 @@ Cards inside a section sit closer than sections. Rows inside a card touch — on
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
Rows revealed under a `<SettingsCollapsibleRow>` are nested rows, one step smaller than card rows: 8px of vertical padding, `fontSize.sm` text, `xs` controls, and one fixed height so rows with and without a trailing button line up. The collapsible row sets this for everything under it (`packages/app/src/components/settings/row-density.tsx`). Never pass a density to a row, and never pad or resize a nested row by hand.
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
---
|
||||
|
||||
@@ -94,12 +94,6 @@ and its Claude provider available. Set `PASEO_COMPOSER_KEYBOARD_SERVER_ID`,
|
||||
`_AGENT_ID` (an idle mock chat), and `_WORKSPACE_ID` (its workspace); `_DEVICE`,
|
||||
`_SESSION`, and `_DRAFT_ID` are optional. Artifacts go to
|
||||
`.dev/agent-device-artifacts/composer-keyboard-ios`.
|
||||
With a Codex draft selected, set `_SCENARIO=growth` on a 402pt-wide iPhone to
|
||||
check live wrapping, shrinking, the 12pt anchor gap, and command insertion. Set
|
||||
`_SCENARIO=previews` on an SE-sized iPhone to check long and short descriptions
|
||||
under an eight-line draft, selection with the keyboard shown and hidden, and
|
||||
file mentions. These scenarios only need `_SERVER_ID` and `_WORKSPACE_ID`;
|
||||
`_UDID` selects an exact simulator and `_ARTIFACTS_DIR` overrides the output path.
|
||||
|
||||
The Android sidebar scroll-retention flow requires a connected host with enough
|
||||
workspaces to overflow the compact sidebar. Pass `TOP_WORKSPACE` and
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,4 +0,0 @@
|
||||
Fixed
|
||||
- Fixed a startup crash with Show background enabled and eight or more workspaces in a project
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-SKL7TMIuuV3imTIaQc66UDf3n6V3FB0nGSBucJ6rlA0=
|
||||
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
|
||||
|
||||
Generated
+70
-38
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -30269,9 +30269,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-reanimated": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.5.tgz",
|
||||
"integrity": "sha512-xtJXZRZ1vkec1AIUVG02St2sbyZS5jc7227TlDu/HonqUYPeZAfiC4RI2jqL1dVMNBu1+7oBx/c9QkjF2kg6Lg==",
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz",
|
||||
"integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-native-is-edge-to-edge": "^1.3.1",
|
||||
@@ -30280,7 +30280,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "0.83 - 0.86",
|
||||
"react-native-worklets": "0.10.x - 0.11.x"
|
||||
"react-native-worklets": "0.10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-safe-area-context": {
|
||||
@@ -30406,9 +30406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-worklets": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.4.tgz",
|
||||
"integrity": "sha512-gbk0CxMrGywEp3SJAHO+2JVaCWtl4WyAYBIxLZy6Y113+mHHuq3FuT6iV+VcaBuJzuuN34mtdZ4j5JIlBDzmeA==",
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz",
|
||||
"integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||
@@ -35695,7 +35695,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -35715,7 +35715,6 @@
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@marijn/find-cluster-break": "^1.0.2",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "0.3.2",
|
||||
"@react-native/normalize-colors": "0.86.3",
|
||||
@@ -35781,14 +35780,14 @@
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "0.10.4",
|
||||
"react-native-worklets": "0.10.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
@@ -37962,12 +37961,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/server": "0.13.1",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/server": "0.13.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38214,10 +38213,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/relay": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38229,7 +38228,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38473,7 +38472,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -41013,6 +41012,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native-worklets": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.13.0.tgz",
|
||||
"integrity": "sha512-1OrhAeAKaZF4XrBa4YbPQbKzNOpjh52HDlId8e6ete1OCHNdAlrQHzG4gznJ225EAj3dgQXHViJvK51M9B52jQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.27.1",
|
||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.28.6",
|
||||
"@babel/plugin-transform-classes": "^7.28.6",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.28.6",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-template-literals": "^7.27.1",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"semver": "^7.7.4",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "*",
|
||||
"@react-native/metro-config": "*",
|
||||
"react": "*",
|
||||
"react-native": "0.86 - 0.88"
|
||||
}
|
||||
},
|
||||
"packages/expo-two-way-audio/node_modules/expo-module-scripts/node_modules/react-native/node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
@@ -42092,7 +42124,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -42121,15 +42153,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -42139,8 +42171,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -42162,7 +42194,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -42189,7 +42221,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -42410,16 +42442,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.13.1",
|
||||
"@getpaseo/highlight": "0.13.1",
|
||||
"@getpaseo/plugin": "0.13.1",
|
||||
"@getpaseo/protocol": "0.13.1",
|
||||
"@getpaseo/relay": "0.13.1",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/highlight": "0.13.0",
|
||||
"@getpaseo/plugin": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -43340,7 +43372,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -44950,7 +44982,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -154,7 +154,7 @@
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-worklets": "0.10.4"
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-worklets": "0.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,7 @@ import {
|
||||
expectComposerVisible,
|
||||
cancelAgent,
|
||||
composerLocator,
|
||||
expectQueuedMessageButton,
|
||||
fillComposerDraft,
|
||||
holdSendButton,
|
||||
sendDraftToQueue,
|
||||
startRunningMockAgent,
|
||||
submitMessage,
|
||||
@@ -1292,49 +1290,6 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("queues a draft by holding Send while a tap still steers", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `hold-send-queue-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep this turn active while the user holds Send.",
|
||||
});
|
||||
try {
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
|
||||
const queuedPrompt = "Queue this follow-up by holding Send.";
|
||||
await test.step("holding Send queues the draft", async () => {
|
||||
await fillComposerDraft(page, queuedPrompt);
|
||||
await holdSendButton(page, "Send and steer");
|
||||
await expectQueuedMessageButton(page);
|
||||
await expectComposerDraft(page, "");
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: queuedPrompt }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("tapping Send still steers", async () => {
|
||||
const steeredPrompt = "Steer this turn with a tap.";
|
||||
await fillComposerDraft(page, steeredPrompt);
|
||||
await page.getByRole("button", { name: "Send and steer", exact: true }).click();
|
||||
|
||||
const request = await gate.waitForRequest();
|
||||
expect(request.text).toBe(steeredPrompt);
|
||||
expect(request.activeTurnBehavior).toBe("steer");
|
||||
gate.accept();
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: steeredPrompt }),
|
||||
).toHaveCount(1);
|
||||
await expectQueuedMessageButton(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
closeImageLightbox,
|
||||
pressInterruptShortcut,
|
||||
expectComposerDraft,
|
||||
expectComposerDisabled,
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
dropFileOnComposer,
|
||||
sendDraftToQueue,
|
||||
@@ -266,9 +268,7 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("composer and attachments stay available while new workspace agent is being created", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -298,9 +298,8 @@ test.describe("Composer attachments", () => {
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
await expectComposerEditable(page);
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expectComposerDisabled(page);
|
||||
await expectAttachButtonDisabled(page);
|
||||
|
||||
agentCreatedDelay.release();
|
||||
|
||||
|
||||
@@ -42,14 +42,6 @@ const test = base.extend<{ agent: MockAgentSession }>({
|
||||
},
|
||||
});
|
||||
|
||||
// The outer meter owns the accessible usage label; its SVG is decorative.
|
||||
async function expectDecorativeContextRing(meter: Locator): Promise<void> {
|
||||
const ring = meter.locator("svg");
|
||||
await expect(ring).toHaveAttribute("aria-hidden", "true");
|
||||
await expect(ring).not.toHaveAttribute("accessibilityElementsHidden");
|
||||
await expect(ring).not.toHaveAttribute("importantForAccessibility");
|
||||
}
|
||||
|
||||
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
||||
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
||||
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
|
||||
@@ -108,7 +100,6 @@ test.describe("context window meter", () => {
|
||||
const meter = page.getByTestId("context-window-meter");
|
||||
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
|
||||
|
||||
await expectDecorativeContextRing(meter);
|
||||
const centroid = await progressArcCentroid(meter);
|
||||
expect(centroid.x).toBeGreaterThan(1);
|
||||
expect(centroid.y).toBeLessThan(-1);
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { expectPickerSelected } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
hideCreationLifecycle,
|
||||
holdWorkspaceProvisioning,
|
||||
reportUnknownPromptOutcome,
|
||||
} from "../support/helpers/creation";
|
||||
import type { createCreationScenario } from "../support/helpers/creation";
|
||||
import { expectComposerMode, expectComposerModel } from "../support/helpers/agent-profiles";
|
||||
import { expectChatHistoryAttachment } from "../support/helpers/assistant-fork";
|
||||
import { expect } from "../support/fixtures";
|
||||
import { test } from "../support/creation-fixtures";
|
||||
|
||||
@@ -113,38 +105,26 @@ test("new workspace navigation and optimistic prompt precede agent initializatio
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
for (const entry of [
|
||||
for (const scenario of [
|
||||
{ suffix: "", prepare: async () => {} },
|
||||
{
|
||||
name: "an agent draft",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openAgentDraft(),
|
||||
button: "Send message",
|
||||
expectWorkspace: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
|
||||
},
|
||||
{
|
||||
name: "a new workspace",
|
||||
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.openWorkspaceForm("local"),
|
||||
button: "Create",
|
||||
expectWorkspace: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.expectOneCreatedWorkspace(),
|
||||
suffix: " after remount",
|
||||
prepare: async (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
|
||||
creation.evictAndReturnToDraft(),
|
||||
},
|
||||
]) {
|
||||
test(`a failed agent start in ${entry.name} keeps the draft, its error and its retry`, async ({
|
||||
test(`retrying failed agent initialization preserves its workspace${scenario.suffix}`, async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "Retry this agent after its startup fails.";
|
||||
await entry.open(creation);
|
||||
await creation.submitPrompt(prompt, entry.button);
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Retry this workspace and agent together.", "Create");
|
||||
await creation.expectAgentStillStarting();
|
||||
await startup.fail();
|
||||
await creation.expectFailedAgentDraft(prompt);
|
||||
await entry.expectWorkspace(creation);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-agent-draft.png") });
|
||||
await creation.resendFailedDraft();
|
||||
await creation.expectPromptVisible(prompt);
|
||||
await entry.expectWorkspace(creation);
|
||||
await creation.expectStartupFailure();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await scenario.prepare(creation);
|
||||
await creation.submitPrompt("Retry this workspace and agent together.");
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
}
|
||||
@@ -191,356 +171,3 @@ for (const entry of [
|
||||
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const isolation of ["local", "worktree"] as const) {
|
||||
test(`a held ${isolation} workspace opens the normal agent panel and queues a follow-up`, async ({
|
||||
creation,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "emit 1 coalesced agent stream updates for the first workspace prompt.";
|
||||
const followUp = "emit 1 coalesced agent stream updates for the queued follow-up.";
|
||||
const draft = "Keep this unsent draft through adoption.";
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm(isolation);
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
const request = await provisioning.waitForRequest();
|
||||
|
||||
await test.step("the final agent panel paints before any host response", async () => {
|
||||
expect(request.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/);
|
||||
expect(request.agent!.agentId).toMatch(/^[a-f0-9-]{36}$/);
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${request.agent!.agentId}`).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await creation.expectPromptVisible(prompt);
|
||||
await creation.expectNormalAgentComposer();
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await expectComposerMode(page, "Load test");
|
||||
});
|
||||
|
||||
await test.step("a follow-up waits in the queue instead of reaching the agent", async () => {
|
||||
await creation.queueFollowUp(followUp);
|
||||
await creation.typeComposerDraft(draft);
|
||||
expect(provisioning.agentPrompts()).toBe(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("held-normal-panel.png") });
|
||||
});
|
||||
|
||||
await test.step("adoption keeps the same agent, draft and queue order", async () => {
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
|
||||
await creation.expectUserMessagesInOrder([prompt, followUp]);
|
||||
expect(provisioning.agentPrompts()).toBe(1);
|
||||
await creation.expectComposerDraft(draft);
|
||||
await creation.expectTimelineSettled();
|
||||
await page.screenshot({ path: testInfo.outputPath("adopted-normal-panel.png") });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("a held workspace on compact shows the final agent prompt and composer", async ({
|
||||
creation,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await creation.submitPrompt("Show my prompt before workspace provisioning.", "Create");
|
||||
const request = await provisioning.waitForRequest();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${request.workspaceId}$`));
|
||||
await creation.expectPromptVisible("Show my prompt before workspace provisioning.");
|
||||
await creation.expectNormalAgentComposer();
|
||||
await creation.expectAgentCannotBeStoppedYet();
|
||||
await page.screenshot({ path: testInfo.outputPath("compact-held.png") });
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
expect(await creation.agentIds()).toEqual([request.agent!.agentId]);
|
||||
await creation.expectInitialPromptOnce("Show my prompt before workspace provisioning.");
|
||||
});
|
||||
|
||||
test("workspace creation recovers a lost response without replaying the first prompt", async ({
|
||||
creation,
|
||||
workspacePromptRetry,
|
||||
}) => {
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
workspacePromptRetry.holdAcknowledgement();
|
||||
await creation.submitPrompt("Recover this workspace prompt once.", "Create");
|
||||
await workspacePromptRetry.waitForDeliveredPrompt();
|
||||
await creation.expectPromptVisible("Recover this workspace prompt once.");
|
||||
await creation.evictAndReturnToDraft();
|
||||
await workspacePromptRetry.disconnectAndReconnect();
|
||||
await workspacePromptRetry.expectSameAgentAndMessage();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("a pending workspace survives leaving and remounting before any daemon event", async ({
|
||||
creation,
|
||||
page,
|
||||
}) => {
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
await creation.submitPrompt("Keep this pending workspace across remount.", "Create");
|
||||
await provisioning.waitForRequest();
|
||||
await creation.expectPromptVisible("Keep this pending workspace across remount.");
|
||||
await creation.evictAndReturnToDraft();
|
||||
await creation.expectPromptVisible("Keep this pending workspace across remount.");
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("a failed workspace returns to New workspace with the prompt and its error", async ({
|
||||
creation,
|
||||
provisioningFailure,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "Keep my prompt when the workspace fails.";
|
||||
await provisioningFailure.arm();
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitRepeatedly("Create", prompt);
|
||||
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-workspace-returned.png") });
|
||||
});
|
||||
|
||||
test("a failed workspace preserves its non-default branch through resubmission", async ({
|
||||
creation,
|
||||
provisioningFailure,
|
||||
page,
|
||||
}) => {
|
||||
const prompt = "Keep the selected worktree base when provisioning fails.";
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("worktree");
|
||||
await creation.selectWorktreeBranch("recovery-base");
|
||||
await provisioningFailure.arm();
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
const first = await provisioning.waitForRequest();
|
||||
expect(first.source).toMatchObject({
|
||||
kind: "worktree",
|
||||
action: "branch-off",
|
||||
refName: "refs/heads/recovery-base",
|
||||
});
|
||||
provisioning.release();
|
||||
await creation.expectReturnedToNewWorkspace(prompt, "Provisioning outcome is uncertain for test");
|
||||
|
||||
await expectPickerSelected(page, "recovery-base");
|
||||
await creation.resubmitWorkspace();
|
||||
const second = await provisioning.waitForRequests(2);
|
||||
expect(second.source).toMatchObject({
|
||||
kind: "worktree",
|
||||
action: "branch-off",
|
||||
refName: "refs/heads/recovery-base",
|
||||
});
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
test("a failed workspace keeps its queued follow-up and unsent text through resubmission", async ({
|
||||
creation,
|
||||
provisioningFailure,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "emit 1 coalesced agent stream updates for the resubmitted workspace.";
|
||||
const followUp = "emit 1 coalesced agent stream updates for the resubmitted follow-up.";
|
||||
const draft = "Keep this unsent text through the failed workspace.";
|
||||
await provisioningFailure.arm();
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
await provisioning.waitForRequest();
|
||||
await creation.queueFollowUp(followUp);
|
||||
await creation.typeComposerDraft(draft);
|
||||
provisioning.release();
|
||||
|
||||
await test.step("New workspace shows the prompt, its error and the queued follow-up", async () => {
|
||||
await creation.expectReturnedToNewWorkspace(
|
||||
prompt,
|
||||
"Provisioning outcome is uncertain for test",
|
||||
);
|
||||
await creation.expectQueuedFollowUp(followUp);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-workspace-follow-up.png") });
|
||||
});
|
||||
|
||||
await test.step("resubmitting delivers the prompt, then the follow-up, once", async () => {
|
||||
await creation.resubmitWorkspace();
|
||||
await provisioning.waitForRequests(2);
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await creation.expectUserMessagesInOrder([prompt, followUp]);
|
||||
await creation.expectComposerDraft(draft);
|
||||
await page.screenshot({ path: testInfo.outputPath("resubmitted-workspace-follow-up.png") });
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed agent start keeps its queued follow-up and unsent text through retry", async ({
|
||||
creation,
|
||||
startup,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "emit 1 coalesced agent stream updates for the retried agent.";
|
||||
const followUp = "emit 1 coalesced agent stream updates for the retried follow-up.";
|
||||
const draft = "Keep this unsent text through the failed agent start.";
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
await creation.queueFollowUp(followUp);
|
||||
await creation.typeComposerDraft(draft);
|
||||
await startup.fail();
|
||||
|
||||
await test.step("the agent draft shows the prompt, its error and the queued follow-up", async () => {
|
||||
await creation.expectFailedAgentDraft(prompt);
|
||||
await creation.expectQueuedFollowUp(followUp);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-agent-follow-up.png") });
|
||||
});
|
||||
|
||||
await test.step("retrying delivers the prompt, then the follow-up, once", async () => {
|
||||
await creation.resendFailedDraft();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await creation.expectUserMessagesInOrder([prompt, followUp]);
|
||||
await creation.expectComposerDraft(draft);
|
||||
await page.screenshot({ path: testInfo.outputPath("retried-agent-follow-up.png") });
|
||||
});
|
||||
});
|
||||
|
||||
test("a forked worktree agent that fails to start keeps its setup and context through retry", async ({
|
||||
creation,
|
||||
lateStartup,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "Keep this fork's setup, image and history through the retry.";
|
||||
await creation.openNestedAgentFork("worktree");
|
||||
await creation.attachImage();
|
||||
const startup = await lateStartup.arm();
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
await startup.fail();
|
||||
|
||||
await test.step("the agent draft keeps the fork's configuration and context", async () => {
|
||||
await creation.expectFailedAgentDraft(prompt);
|
||||
await expectComposerModel(page, "e2e-fast-stream");
|
||||
await expectComposerMode(page, "Load test");
|
||||
await creation.expectImageVisible();
|
||||
await expectChatHistoryAttachment(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-fork-draft.png") });
|
||||
});
|
||||
|
||||
// An agent created in an existing workspace starts at the workspace root: the host ignores
|
||||
// config.cwd when the request names a workspace, so the nested cwd is not asserted here.
|
||||
await test.step("retrying creates one agent in the worktree with that configuration and context", async () => {
|
||||
await creation.resendFailedDraft();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await creation.expectAgentConfig({ model: "e2e-fast-stream", modeId: "load-test" });
|
||||
await creation.expectImageVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("user-message")
|
||||
.filter({ hasText: "Chat history" })
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await page.screenshot({ path: testInfo.outputPath("retried-fork-agent.png") });
|
||||
});
|
||||
});
|
||||
|
||||
test("a forked worktree whose workspace fails keeps its context through resubmission", async ({
|
||||
creation,
|
||||
provisioningFailure,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const prompt = "Keep this fork's history when its workspace fails.";
|
||||
await creation.openNestedAgentFork("worktree");
|
||||
await provisioningFailure.arm();
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
|
||||
await test.step("New workspace shows the prompt, its error and the fork's context", async () => {
|
||||
await creation.expectReturnedToNewWorkspace(
|
||||
prompt,
|
||||
"Provisioning outcome is uncertain for test",
|
||||
);
|
||||
await expectChatHistoryAttachment(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("failed-fork-workspace.png") });
|
||||
});
|
||||
|
||||
await test.step("resubmitting creates one agent with that context", async () => {
|
||||
await creation.resubmitWorkspace();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("user-message")
|
||||
.filter({ hasText: "Chat history" })
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await page.screenshot({ path: testInfo.outputPath("resubmitted-fork-workspace.png") });
|
||||
});
|
||||
});
|
||||
|
||||
test("an unsupported host keeps the draft and asks for an update before creating anything", async ({
|
||||
creation,
|
||||
page,
|
||||
}) => {
|
||||
const host = await hideCreationLifecycle(page);
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Retain my unsupported host draft.", "Create");
|
||||
await expect(
|
||||
page
|
||||
.getByText("Update this host to create a workspace with an agent.", { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/new\?/);
|
||||
host.expectNoCreationRequest();
|
||||
});
|
||||
|
||||
for (const isolation of ["local", "worktree"] as const) {
|
||||
test(`a fork keeps its image and nested cwd in the optimistic and real ${isolation} agent`, async ({
|
||||
creation,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
const cwd = await creation.openNestedAgentFork(isolation);
|
||||
await creation.attachImage();
|
||||
await creation.submitPrompt("Keep this image and nested cwd.", "Create");
|
||||
const request = await provisioning.waitForRequest();
|
||||
expect(request.agent!.config?.cwd).toBe(cwd);
|
||||
expect(request.agent!.images).toHaveLength(1);
|
||||
await creation.expectPromptVisible("Keep this image and nested cwd.");
|
||||
await creation.expectImageVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("pending-image-nested-cwd.png") });
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectNestedAgentCwd();
|
||||
await creation.expectImageVisible();
|
||||
await creation.expectInitialPromptOnce("Keep this image and nested cwd.");
|
||||
});
|
||||
}
|
||||
|
||||
test("closing the pending agent tab leaves host creation running", async ({ creation, page }) => {
|
||||
const provisioning = await holdWorkspaceProvisioning(page);
|
||||
await creation.openWorkspaceForm("local");
|
||||
await creation.submitPrompt("Finish creation after I close the tab.", "Create");
|
||||
const request = await provisioning.waitForRequest();
|
||||
await creation.expectPromptVisible("Finish creation after I close the tab.");
|
||||
await creation.closePendingAgent(request.agent!.agentId!);
|
||||
provisioning.release();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectAgentCount(1);
|
||||
await creation.expectInitialPromptOnce("Finish creation after I close the tab.");
|
||||
});
|
||||
|
||||
test("an uncertain prompt receipt keeps the created agent tab and its timeline", async ({
|
||||
creation,
|
||||
page,
|
||||
}) => {
|
||||
await reportUnknownPromptOutcome(page);
|
||||
await creation.openWorkspaceForm("local");
|
||||
const prompt = "emit 1 coalesced agent stream updates for an uncertain first prompt.";
|
||||
await creation.submitPrompt(prompt, "Create");
|
||||
await creation.expectAssistantReply();
|
||||
await creation.expectAgentNotWorking();
|
||||
await creation.expectOneCreatedWorkspace();
|
||||
await creation.expectInitialPromptOnce(prompt);
|
||||
await creation.expectNoCreationRecovery();
|
||||
await creation.expectAgentCount(1);
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("current app requires a host update for chat workspaces and preserves existing-workspace agent creation", async ({
|
||||
test("current app creates a workspace, navigates before agent startup, and creates another agent on an old daemon", async ({
|
||||
page,
|
||||
project,
|
||||
client,
|
||||
@@ -104,34 +104,49 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const prompt = "Create this workspace: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, prompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Create");
|
||||
await gate.waitForRequest();
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(
|
||||
page
|
||||
.getByText("Update this host to create a workspace with an agent.", { exact: true })
|
||||
.first(),
|
||||
page.getByTestId("user-message").filter({ visible: true }).filter({ hasText: prompt }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/new\?/);
|
||||
expect((await client.fetchAgents()).entries).toHaveLength(0);
|
||||
expect(
|
||||
(await client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) => workspace.projectId === project.projectId,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
await page.goto(buildHostWorkspaceRoute(daemon.serverId, project.workspaceId));
|
||||
const workspaces = (await client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
await expectPromptOnce(client, first.id, prompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${first.id}`).filter({ visible: true }),
|
||||
).toHaveText(prompt);
|
||||
|
||||
await createAgentTabFromMenu(page);
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForRequest();
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const agent = (await client.fetchAgents()).entries[0]!.agent;
|
||||
expect(agent.workspaceId).toBe(project.workspaceId);
|
||||
await client.waitForFinish(agent.id, 20_000);
|
||||
await expectPromptOnce(client, agent.id, secondPrompt);
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
)!.agent;
|
||||
expect(second.workspaceId).toBe(first.workspaceId);
|
||||
await client.waitForFinish(second.id, 20_000);
|
||||
await expectPromptOnce(client, second.id, secondPrompt);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }),
|
||||
page.getByTestId(`workspace-tab-agent_${second.id}`).filter({ visible: true }),
|
||||
).toHaveText(secondPrompt);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("user-message")
|
||||
.filter({ visible: true })
|
||||
.filter({ hasText: secondPrompt }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("old-daemon-creation.png") });
|
||||
});
|
||||
|
||||
async function expectPromptOnce(client: DaemonClient, agentId: string, text: string) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { observeCreationRequests } from "../support/helpers/creation";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
fillComposerDraft,
|
||||
exitComposerFullscreen,
|
||||
resizeComposerViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
@@ -96,7 +95,6 @@ test.describe("New workspace composer draft", () => {
|
||||
|
||||
test("does not restore a submitted draft after deferred publication", async ({ page }) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
|
||||
const requests = await observeCreationRequests(page);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -136,9 +134,6 @@ test.describe("New workspace composer draft", () => {
|
||||
}, DRAFT);
|
||||
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
|
||||
// Navigation now precedes creation; wait for success before checking consumed input.
|
||||
await expect.poll(requests.workspaceRequests).toBe(1);
|
||||
await requests.settled();
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceDraft(page, "");
|
||||
} finally {
|
||||
@@ -147,11 +142,10 @@ test.describe("New workspace composer draft", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("fullscreen creation opens the inline agent composer and keeps follow-up editing available", async ({
|
||||
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
|
||||
page,
|
||||
}) => {
|
||||
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
|
||||
const followUpDraft = `${DRAFT}\nKeep this follow-up draft.`;
|
||||
const delay = await delayBrowserWorkspaceCreatedResponse(page);
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -163,23 +157,25 @@ test("fullscreen creation opens the inline agent composer and keeps follow-up ed
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await delay.waitForCreateRequest();
|
||||
|
||||
await test.step("submission opens the normal agent panel inline", async () => {
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await test.step("submission returns inline with the original draft and spinner", async () => {
|
||||
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
|
||||
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(DRAFT);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
});
|
||||
await test.step("fullscreen can reopen to edit a follow-up while creation is pending", async () => {
|
||||
await fillComposerDraft(page, followUpDraft);
|
||||
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
|
||||
await enterComposerFullscreen(page);
|
||||
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
|
||||
await expect(composerLocator(page)).toHaveValue(followUpDraft);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(composerLocator(page)).toHaveValue(DRAFT);
|
||||
await expect(composerLocator(page)).not.toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("progressbar")).toBeVisible();
|
||||
await exitComposerFullscreen(page);
|
||||
});
|
||||
delay.release();
|
||||
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
|
||||
await expect(composerLocator(page)).toHaveValue(followUpDraft);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
} finally {
|
||||
|
||||
@@ -192,16 +192,11 @@ test.describe("New Workspace dictation submit", () => {
|
||||
await harness.waitForCreateRequest();
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await expect(page.getByTestId("user-message").filter({ visible: true })).toContainText(
|
||||
TRANSCRIPT,
|
||||
);
|
||||
await expect(composer).toHaveValue("");
|
||||
await expect(composer).toBeEditable();
|
||||
await expect(composer).toHaveValue(TRANSCRIPT);
|
||||
await expect(composer).not.toBeEditable();
|
||||
|
||||
harness.failWorkspaceCreation();
|
||||
|
||||
await expect(page).toHaveURL(/\/new\?/);
|
||||
await expect(composer).toBeEditable();
|
||||
await expect(composer).toHaveValue(TRANSCRIPT);
|
||||
await expect(page.getByText(CREATE_FAILURE).first()).toBeVisible();
|
||||
|
||||
@@ -504,7 +504,7 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the final agent tab before agent creation resolves", async ({ page }) => {
|
||||
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
||||
@@ -568,10 +568,10 @@ test.describe("New workspace flow", () => {
|
||||
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
|
||||
await expect(draftTabs).toHaveCount(0);
|
||||
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(
|
||||
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||
).toHaveCount(1, { timeout: 30_000 });
|
||||
).toHaveCount(0);
|
||||
|
||||
agentCreatedDelay.release();
|
||||
await expect(
|
||||
|
||||
@@ -10,15 +10,6 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
closeCompactSettings,
|
||||
scrollSettingsSidebarToEnd,
|
||||
revealSettingsSidebarSection,
|
||||
expectSettingsSidebarScrollSettled,
|
||||
scrollSettingsSidebarUp,
|
||||
changeSettingsInterfaceFontSize,
|
||||
selectSettingsHostSectionsRapidly,
|
||||
expectSettingsSidebarScrollOffset,
|
||||
scrollSettingsDetailDown,
|
||||
expectSettingsDetailScrollOffset,
|
||||
openSettingsSection,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
@@ -63,98 +54,6 @@ async function openWorkspace(
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar scroll position", () => {
|
||||
test.use({ viewport: { width: 1000, height: 500 } });
|
||||
|
||||
test("clamps after resizing and starts a fresh visit at the top", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await scrollSettingsSidebarToEnd(page);
|
||||
await page.setViewportSize({ width: 1000, height: 1100 });
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
await page.setViewportSize({ width: 1000, height: 500 });
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
await scrollSettingsSidebarToEnd(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await openSettings(page);
|
||||
await expectSettingsSidebarScrollOffset(page, 0);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position through interface font changes", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
const offset = await scrollSettingsSidebarToEnd(page);
|
||||
await changeSettingsInterfaceFontSize(page, 20);
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
});
|
||||
|
||||
test("scrolls the sidebar and detail pane independently", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
const sidebarOffset = await scrollSettingsSidebarToEnd(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
const detailOffset = await scrollSettingsDetailDown(page);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset);
|
||||
|
||||
await scrollSettingsSidebarUp(page, 60);
|
||||
await expectSettingsSidebarScrollOffset(page, sidebarOffset - 60);
|
||||
await expectSettingsDetailScrollOffset(page, detailOffset);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position after rapid consecutive selections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const scrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
|
||||
await selectSettingsHostSectionsRapidly(page);
|
||||
await expectSettingsSidebarScrollOffset(page, scrollTop);
|
||||
|
||||
await expectSettingsSidebarScrollSettled(page, scrollTop);
|
||||
});
|
||||
|
||||
test("keeps the sidebar position when switching between app and host sections", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const hostScrollTop = await scrollSettingsSidebarToEnd(page);
|
||||
await expect(page.getByTestId("settings-host-section-providers")).toBeInViewport();
|
||||
|
||||
for (const section of [
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
"terminals",
|
||||
"providers",
|
||||
] as const) {
|
||||
await openSettingsHostSection(page, getServerId(), section);
|
||||
await expect(page.getByTestId("page-title")).toHaveText(
|
||||
section === "providers" ? "Providers" : "Terminals",
|
||||
);
|
||||
await expectSettingsSidebarScrollOffset(page, hostScrollTop);
|
||||
}
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const appScrollTop = await revealSettingsSidebarSection(page, "diagnostics");
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeInViewport();
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAboutContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarScrollOffset(page, appScrollTop);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("opening Settings closes the composer model selector until it is opened again", async ({
|
||||
page,
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
apiKeyInput,
|
||||
baseUrlInput,
|
||||
expectSheetClosed,
|
||||
expectSpeechSiteSummary,
|
||||
openSpeechSite,
|
||||
openVoiceSettings,
|
||||
providerOption,
|
||||
readSpeechSite,
|
||||
resetSpeechSite,
|
||||
saveSpeechSite,
|
||||
withUnwritableDaemonConfig,
|
||||
} from "../support/helpers/voice-settings";
|
||||
|
||||
// E2E daemons pin every speech provider to OpenAI through env vars so no test starts a model
|
||||
// download. That makes the provider picker read-only here; the env key covers the key fallback.
|
||||
const ENV_API_KEY = "sk-e2e-env-fallback";
|
||||
const SAVED_API_KEY = "sk-e2e-saved-key";
|
||||
const BASE_URL = "http://127.0.0.1:9/v1";
|
||||
|
||||
test.use({ e2eDaemonEnvironment: { OPENAI_API_KEY: ENV_API_KEY } });
|
||||
|
||||
test("configures an env-pinned OpenAI site and keeps the saved key on later saves", async ({
|
||||
page,
|
||||
}) => {
|
||||
try {
|
||||
await openVoiceSettings(page);
|
||||
await expectSpeechSiteSummary(page, "Listening", "OpenAI · whisper-1");
|
||||
|
||||
const sheet = await test.step("the env var decides the provider", async () => {
|
||||
const opened = await openSpeechSite(page, "Listening");
|
||||
await expect(opened.getByText("Set by PASEO_VOICE_STT_PROVIDER")).toBeVisible();
|
||||
await expect(providerOption(opened, "Local")).toBeDisabled();
|
||||
await expect(providerOption(opened, "OpenAI")).toHaveAttribute("aria-selected", "true");
|
||||
await expect(opened.getByText("Set by OPENAI_API_KEY")).toBeVisible();
|
||||
return opened;
|
||||
});
|
||||
|
||||
await test.step("a saved key and base URL win over the env key", async () => {
|
||||
await apiKeyInput(sheet).fill(SAVED_API_KEY);
|
||||
await baseUrlInput(sheet).fill(BASE_URL);
|
||||
await saveSpeechSite(sheet);
|
||||
await expectSheetClosed(page);
|
||||
await expect
|
||||
.poll(() => readSpeechSite("voiceMode.stt"))
|
||||
.toMatchObject({ provider: "openai", hasApiKey: true, baseUrl: BASE_URL });
|
||||
});
|
||||
|
||||
await test.step("reopening shows the key as saved without revealing it", async () => {
|
||||
const reopened = await openSpeechSite(page, "Listening");
|
||||
await expect(apiKeyInput(reopened)).toHaveValue("");
|
||||
await expect(apiKeyInput(reopened)).toHaveAttribute("placeholder", "Saved");
|
||||
await expect(reopened.getByText("Set by OPENAI_API_KEY")).toHaveCount(0);
|
||||
await expect(baseUrlInput(reopened)).toHaveValue(BASE_URL);
|
||||
await expect(reopened.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
await test.step("clearing the base URL keeps the saved key", async () => {
|
||||
const sheetAgain = page.getByRole("dialog");
|
||||
await baseUrlInput(sheetAgain).fill("");
|
||||
await saveSpeechSite(sheetAgain);
|
||||
await expectSheetClosed(page);
|
||||
const site = await readSpeechSite("voiceMode.stt");
|
||||
expect(site).toMatchObject({ provider: "openai", hasApiKey: true });
|
||||
expect(site).not.toHaveProperty("baseUrl");
|
||||
});
|
||||
} finally {
|
||||
await resetSpeechSite("voiceMode.stt");
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed save keeps the sheet open with the user's input", async ({ page }) => {
|
||||
try {
|
||||
await openVoiceSettings(page);
|
||||
const sheet = await openSpeechSite(page, "Speaking");
|
||||
await baseUrlInput(sheet).fill(BASE_URL);
|
||||
|
||||
await withUnwritableDaemonConfig(async () => {
|
||||
await saveSpeechSite(sheet);
|
||||
await expect(page.getByText(/^Unable to update voice settings: /)).toBeVisible();
|
||||
});
|
||||
|
||||
await expect(baseUrlInput(sheet)).toHaveValue(BASE_URL);
|
||||
await saveSpeechSite(sheet);
|
||||
await expectSheetClosed(page);
|
||||
await expect
|
||||
.poll(() => readSpeechSite("voiceMode.tts"))
|
||||
.toMatchObject({ provider: "openai", baseUrl: BASE_URL });
|
||||
} finally {
|
||||
await resetSpeechSite("voiceMode.tts");
|
||||
}
|
||||
});
|
||||
@@ -28,10 +28,7 @@ function contains(outer, inner) {
|
||||
* while suggestions are open, so its callers read the composer from `reference`: a
|
||||
* snapshot in the same keyboard state with an empty input.
|
||||
*/
|
||||
export function resolveAutocompletePlacement(
|
||||
snapshot,
|
||||
{ expected, keyboardTop, reference, anchorGap },
|
||||
) {
|
||||
export function resolveAutocompletePlacement(snapshot, { expected, keyboardTop, reference }) {
|
||||
if (!snapshot.success || !snapshot.data?.nodes?.length) throw new Error("Empty device snapshot");
|
||||
const nodes = snapshot.data.nodes;
|
||||
const popover = findById(nodes, "composer-autocomplete-popover").rect;
|
||||
@@ -43,10 +40,6 @@ export function resolveAutocompletePlacement(
|
||||
const keyboardY =
|
||||
keyboardTop ?? nodes.find((node) => node.type === "Keyboard" && node.rect)?.rect.y;
|
||||
const popoverBottom = bottomOf(popover);
|
||||
const gap = composer.y - popoverBottom;
|
||||
if (anchorGap !== undefined && Math.abs(gap - anchorGap) > 1) {
|
||||
throw new Error(`Autocomplete lost its anchor gap: gap=${gap}, expected=${anchorGap}`);
|
||||
}
|
||||
if (popoverBottom > composer.y + 1) {
|
||||
throw new Error(
|
||||
`Autocomplete overlaps the composer: popover bottom=${popoverBottom}, composer top=${composer.y}`,
|
||||
|
||||
@@ -14,8 +14,7 @@ const env = (name, fallback) => process.env[`PASEO_COMPOSER_KEYBOARD_${name}`] ?
|
||||
const serverId = env("SERVER_ID");
|
||||
const agentId = env("AGENT_ID");
|
||||
const workspaceId = env("WORKSPACE_ID");
|
||||
const scenario = env("SCENARIO", "all");
|
||||
if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
|
||||
if (!serverId || !agentId || !workspaceId) {
|
||||
throw new Error(
|
||||
"Set PASEO_COMPOSER_KEYBOARD_SERVER_ID, _AGENT_ID (an idle chat) and _WORKSPACE_ID (its workspace)",
|
||||
);
|
||||
@@ -23,12 +22,7 @@ if (!serverId || !workspaceId || (scenario === "all" && !agentId)) {
|
||||
const draftId = env("DRAFT_ID", "composer-keyboard");
|
||||
const session = env("SESSION", "composer-keyboard-ios");
|
||||
const device = env("DEVICE", "iPhone 16 Pro");
|
||||
const udid = env("UDID");
|
||||
const deviceArgs = udid ? ["--udid", udid] : ["--device", device];
|
||||
const artifactsDir = env(
|
||||
"ARTIFACTS_DIR",
|
||||
path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios"),
|
||||
);
|
||||
const artifactsDir = path.join(repoRoot, ".dev/agent-device-artifacts/composer-keyboard-ios");
|
||||
const grownPrefix = "grown line one\ngrown line two\ngrown line three\n";
|
||||
// Partial queries with known best matches. `inserted` is the composer text after
|
||||
// tapping the best match.
|
||||
@@ -41,7 +35,7 @@ const chatCommandCase = { text: "/mo", expected: "/mock", inserted: "/mock " };
|
||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [args[0], "--session", session, ...args.slice(1)], {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
@@ -83,7 +77,7 @@ async function settledPlacement(name, keyboard) {
|
||||
}
|
||||
|
||||
function assertPlacement(name, snap, expected) {
|
||||
const placement = resolveAutocompletePlacement(snap, { expected, anchorGap: 12 });
|
||||
const placement = resolveAutocompletePlacement(snap, { expected });
|
||||
process.stdout.write(
|
||||
`PASS ${name}: popover ${Math.round(placement.popover.y)}-${Math.round(
|
||||
placement.popover.y + placement.popover.height,
|
||||
@@ -129,34 +123,21 @@ async function clearInput(name) {
|
||||
)?.rect;
|
||||
if (!deleteKey) throw new Error("Missing keyboard delete key");
|
||||
const length = composerText(keyboard).length;
|
||||
for (let remaining = length + 2; remaining > 0; remaining -= 100) {
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(deleteKey.x + deleteKey.width / 2)),
|
||||
String(Math.round(deleteKey.y + deleteKey.height / 2)),
|
||||
"--count",
|
||||
String(Math.min(remaining, 100)),
|
||||
"--interval-ms",
|
||||
"40",
|
||||
);
|
||||
}
|
||||
ad(
|
||||
"press",
|
||||
String(Math.round(deleteKey.x + deleteKey.width / 2)),
|
||||
String(Math.round(deleteKey.y + deleteKey.height / 2)),
|
||||
"--count",
|
||||
String(length + 2),
|
||||
"--interval-ms",
|
||||
"40",
|
||||
);
|
||||
}
|
||||
|
||||
// The accessibility value drops a trailing space.
|
||||
const sameText = (actual, expected) => actual.trimEnd() === expected.trimEnd();
|
||||
|
||||
async function selectRow(name, row, inserted) {
|
||||
ad("press", String(row.x), String(row.y));
|
||||
const selected = await waitFor(`${name}-selected`, (snap) =>
|
||||
sameText(composerText(snap), inserted),
|
||||
);
|
||||
if (!keyboardShown(selected)) throw new Error(`Selecting ${row.label} closed the keyboard`);
|
||||
process.stdout.write(
|
||||
`PASS ${name}: tapped ${row.label}, composer now ${JSON.stringify(inserted)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runCase(host, kind, { text, expected, inserted, selectionKeyboard = "reopened" }) {
|
||||
async function runCase(host, kind, { text, expected, inserted }) {
|
||||
const name = `${host}-${kind}`;
|
||||
await clearInput(name);
|
||||
await dismissKeyboard(snapshot(`${name}-empty`));
|
||||
@@ -170,32 +151,31 @@ async function runCase(host, kind, { text, expected, inserted, selectionKeyboard
|
||||
await clearInput(`${name}-settled`);
|
||||
await waitFor(`${name}-settled-empty`, (snap) => composerText(snap) === "");
|
||||
ad("type", text);
|
||||
const opened = assertPlacement(
|
||||
assertPlacement(
|
||||
`${name} opened after keyboard settled`,
|
||||
await settledPlacement(`${name}-open`, true),
|
||||
expected,
|
||||
);
|
||||
if (selectionKeyboard === "shown") {
|
||||
await selectRow(name, opened, inserted);
|
||||
return;
|
||||
}
|
||||
await dismissKeyboard(snapshot(`${name}-before-close`));
|
||||
const closed = assertPlacement(
|
||||
assertPlacement(
|
||||
`${name} keyboard closed`,
|
||||
await settledPlacement(`${name}-closed`, false),
|
||||
expected,
|
||||
);
|
||||
if (selectionKeyboard === "hidden") {
|
||||
await selectRow(name, closed, inserted);
|
||||
return;
|
||||
}
|
||||
focusAtEnd(snapshot(`${name}-before-reopen`));
|
||||
const reopened = assertPlacement(
|
||||
`${name} keyboard opened under open suggestions`,
|
||||
await settledPlacement(`${name}-reopened`, true),
|
||||
expected,
|
||||
);
|
||||
await selectRow(name, reopened, inserted);
|
||||
ad("press", String(reopened.x), String(reopened.y));
|
||||
const selected = await waitFor(`${name}-selected`, (snap) =>
|
||||
sameText(composerText(snap), inserted),
|
||||
);
|
||||
if (!keyboardShown(selected)) throw new Error(`Selecting ${expected} closed the keyboard`);
|
||||
process.stdout.write(
|
||||
`PASS ${name}: tapped ${expected}, composer now ${JSON.stringify(inserted)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Drafts list commands for their selected provider, and a fresh install selects none.
|
||||
@@ -236,104 +216,13 @@ async function runHost(host) {
|
||||
await dismissKeyboard(snapshot(`${host}-done`));
|
||||
}
|
||||
|
||||
// These cases use the selected draft provider's real skills. Run on a 402pt-wide
|
||||
// iPhone for growth and a small phone (SE) for previews, with Codex selected.
|
||||
async function runLiveGrowth() {
|
||||
await clearInput("live-growth");
|
||||
ad("type", "Please do the following now /paseo");
|
||||
const before = await settledPlacement("live-growth-before", true);
|
||||
assertPlacement("before growth", before, "/paseo");
|
||||
const inputHeight = (snap) =>
|
||||
snap.data.nodes.find((node) => node.type === "TextView").rect.height;
|
||||
ad("type", "--", "-committee");
|
||||
const grown = await settledPlacement("live-growth-after", true);
|
||||
if (inputHeight(grown) <= inputHeight(before))
|
||||
throw new Error("Draft did not wrap onto a new line");
|
||||
assertPlacement("live growth without reopening", grown, "/paseo-committee");
|
||||
const deleteKey = grown.data.nodes.find(
|
||||
(node) => node.type === "Key" && node.label === "delete",
|
||||
).rect;
|
||||
ad(
|
||||
"press",
|
||||
String(deleteKey.x + deleteKey.width / 2),
|
||||
String(deleteKey.y + deleteKey.height / 2),
|
||||
"--count",
|
||||
"10",
|
||||
"--interval-ms",
|
||||
"40",
|
||||
);
|
||||
const shrunk = await settledPlacement("live-growth-shrunk", true);
|
||||
if (inputHeight(shrunk) !== inputHeight(before))
|
||||
throw new Error("Draft did not shrink back to one line");
|
||||
assertPlacement("live shrink without reopening", shrunk, "/paseo");
|
||||
ad("type", "--", "-committ");
|
||||
const partial = await settledPlacement("live-growth-partial", true);
|
||||
if (!sameText(composerText(partial), "Please do the following now /paseo-committ")) {
|
||||
throw new Error("Could not prepare a partial command for selection");
|
||||
}
|
||||
const row = assertPlacement("partial command after growth", partial, "/paseo-committee");
|
||||
ad("press", String(row.x), String(row.y));
|
||||
await waitFor("live-growth-selected", (snap) =>
|
||||
sameText(composerText(snap), "Please do the following now /paseo-committee "),
|
||||
);
|
||||
await clearInput("live-growth-done");
|
||||
}
|
||||
|
||||
async function runBoundedPreview() {
|
||||
const prefix =
|
||||
"Please do the following / testing inline selection.\n" +
|
||||
"Line two with details.\nLine three with details.\nLine four with details.\n" +
|
||||
"Line five with details.\nLine six with details.\nLine seven with details.\nPlease use ";
|
||||
await runCase("preview", "long", {
|
||||
text: `${prefix}/openai`,
|
||||
expected: "/openai-docs",
|
||||
inserted: `${prefix}/openai-docs `,
|
||||
selectionKeyboard: "shown",
|
||||
});
|
||||
await runCase("preview", "long-hidden", {
|
||||
text: `${prefix}/openai`,
|
||||
expected: "/openai-docs",
|
||||
inserted: `${prefix}/openai-docs `,
|
||||
selectionKeyboard: "hidden",
|
||||
});
|
||||
await runCase("preview", "longest", {
|
||||
text: `${prefix}/paseo-plug`,
|
||||
expected: "/paseo-plugin",
|
||||
inserted: `${prefix}/paseo-plugin `,
|
||||
selectionKeyboard: "shown",
|
||||
});
|
||||
await runCase("preview", "short", {
|
||||
text: `${prefix}/com`,
|
||||
expected: "/commit",
|
||||
inserted: `${prefix}/commit `,
|
||||
selectionKeyboard: "shown",
|
||||
});
|
||||
await runCase("preview", "files", fileCase);
|
||||
}
|
||||
|
||||
if (scenario !== "all") {
|
||||
ad(
|
||||
"open",
|
||||
`paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`,
|
||||
"--platform",
|
||||
"ios",
|
||||
...deviceArgs,
|
||||
);
|
||||
await waitFor("draft", (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
|
||||
if (scenario === "growth") await runLiveGrowth();
|
||||
else if (scenario === "previews") await runBoundedPreview();
|
||||
else throw new Error(`Unknown scenario: ${scenario}`);
|
||||
process.stdout.write(`Composer ${scenario} regression passed. Artifacts: ${artifactsDir}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const hosts = [
|
||||
["chat", `paseo://h/${serverId}/agent/${agentId}`],
|
||||
["workspace-draft", `paseo://h/${serverId}/workspace/${workspaceId}?open=draft%3A${draftId}`],
|
||||
["new-workspace", `paseo://new?serverId=${serverId}`],
|
||||
];
|
||||
for (const [host, url] of hosts) {
|
||||
ad("open", url, "--platform", "ios", ...deviceArgs);
|
||||
ad("open", url, "--platform", "ios", "--device", device);
|
||||
await waitFor(host, (snap) => Boolean(nodeById(snap, "message-input-root")), 60_000);
|
||||
await runHost(host);
|
||||
}
|
||||
|
||||
@@ -11,24 +11,27 @@ import {
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
|
||||
interface StartupGate {
|
||||
release(): Promise<void>;
|
||||
fail(): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Holds every later workspace agent's startup until it is released or failed once. */
|
||||
async function installStartupGate(): Promise<StartupGate> {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const plugin = await copyPluginExample("lifecycle-logger");
|
||||
const gate = path.join(plugin.directory, "startup.txt");
|
||||
const failure = path.join(plugin.directory, "startup-failure.txt");
|
||||
const previous = await client.getDaemonConfig();
|
||||
await writeFile(gate, "hold");
|
||||
await writeFile(failure, "fail");
|
||||
await writeFile(
|
||||
path.join(plugin.directory, "index.server.ts"),
|
||||
`
|
||||
export const test = base.extend<{
|
||||
startup: { release(): Promise<void>; fail(): Promise<void> };
|
||||
promptRejection: void;
|
||||
creation: Awaited<ReturnType<typeof createCreationScenario>>;
|
||||
promptRetry: ReturnType<typeof createPromptRetryScenario>;
|
||||
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
|
||||
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
|
||||
}>({
|
||||
startup: async ({ page }, provide) => {
|
||||
void page;
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const plugin = await copyPluginExample("lifecycle-logger");
|
||||
const gate = path.join(plugin.directory, "startup.txt");
|
||||
const failure = path.join(plugin.directory, "startup-failure.txt");
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await writeFile(gate, "hold");
|
||||
await writeFile(failure, "fail");
|
||||
await writeFile(
|
||||
path.join(plugin.directory, "index.server.ts"),
|
||||
`
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
export default function contribute(server) {
|
||||
return server.before("agent.session_open", async ({ request }) => {
|
||||
@@ -47,88 +50,17 @@ export default function contribute(server) {
|
||||
return request;
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
return {
|
||||
release: () => rm(gate, { force: true }),
|
||||
// Replacing the command must not expose a truncated, empty file to the hook.
|
||||
fail: () => rename(failure, gate),
|
||||
async dispose() {
|
||||
await rm(gate, { force: true });
|
||||
await client.removePlugin("lifecycle-logger");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await plugin.cleanup();
|
||||
await client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const test = base.extend<{
|
||||
startup: StartupGate;
|
||||
/** A startup gate installed mid-test, after agents that must start normally. */
|
||||
lateStartup: { arm(): Promise<StartupGate> };
|
||||
promptRejection: void;
|
||||
/** Fails the next workspace request once it is armed. */
|
||||
provisioningFailure: { arm(): Promise<void> };
|
||||
creation: Awaited<ReturnType<typeof createCreationScenario>>;
|
||||
promptRetry: ReturnType<typeof createPromptRetryScenario>;
|
||||
workspacePromptRetry: ReturnType<typeof createPromptRetryScenario>;
|
||||
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
|
||||
delayedCreation: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
|
||||
}>({
|
||||
startup: async ({ page }, provide) => {
|
||||
void page;
|
||||
const gate = await installStartupGate();
|
||||
try {
|
||||
await provide(gate);
|
||||
} finally {
|
||||
await gate.dispose();
|
||||
}
|
||||
},
|
||||
lateStartup: async ({ page }, provide) => {
|
||||
void page;
|
||||
const gates: StartupGate[] = [];
|
||||
try {
|
||||
await provide({
|
||||
arm: async () => {
|
||||
const gate = await installStartupGate();
|
||||
gates.push(gate);
|
||||
return gate;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
for (const gate of gates) await gate.dispose();
|
||||
}
|
||||
},
|
||||
provisioningFailure: async ({ page, creation }, provide) => {
|
||||
void page;
|
||||
void creation;
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const plugin = await copyPluginExample("lifecycle-logger");
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
const failure = path.join(plugin.directory, "provisioning-failure.txt");
|
||||
// Fails one workspace request per arm, so a resubmission can succeed.
|
||||
await writeFile(
|
||||
path.join(plugin.directory, "index.server.ts"),
|
||||
`
|
||||
import { rm, stat } from "node:fs/promises";
|
||||
export default function contribute(server) {
|
||||
return server.before("workspace.create", async ({ request }) => {
|
||||
const failure = ${JSON.stringify(failure)};
|
||||
if (!(await stat(failure).catch(() => null))) return request;
|
||||
await rm(failure, { force: true });
|
||||
throw new Error("Provisioning outcome is uncertain for test");
|
||||
});
|
||||
}
|
||||
`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
await provide({ arm: () => writeFile(failure, "fail") });
|
||||
await provide({
|
||||
release: () => rm(gate, { force: true }),
|
||||
// Replacing the command must not expose a truncated, empty file to the hook.
|
||||
fail: () => rename(failure, gate),
|
||||
});
|
||||
} finally {
|
||||
await rm(gate, { force: true });
|
||||
await client.removePlugin("lifecycle-logger");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await plugin.cleanup();
|
||||
@@ -182,14 +114,6 @@ export default function contribute(server) {
|
||||
gate.restore();
|
||||
}
|
||||
},
|
||||
workspacePromptRetry: async ({ page }, provide) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
try {
|
||||
await provide(createPromptRetryScenario(page, gate, "workspace"));
|
||||
} finally {
|
||||
gate.restore();
|
||||
}
|
||||
},
|
||||
agentRetries: async ({ page }, provide) => {
|
||||
await provide(await retryNextAgentCreation(page));
|
||||
},
|
||||
|
||||
@@ -87,14 +87,6 @@ export async function sendDraftToQueue(page: Page): Promise<void> {
|
||||
await composerInput(page).press("Control+Enter");
|
||||
}
|
||||
|
||||
/** Long-press the Send button: the composer takes the draft while the pointer is still down. */
|
||||
export async function holdSendButton(page: Page, label: string): Promise<void> {
|
||||
await page.getByRole("button", { name: label, exact: true }).hover();
|
||||
await page.mouse.down();
|
||||
await expect(composerInput(page)).toHaveValue("", { timeout: 5_000 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function expectQueuedMessageButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { awaitAssistantMessage } from "./agent-stream";
|
||||
import { forkMostRecentAssistantTurnToNewWorkspace } from "./assistant-fork";
|
||||
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { gotoWorkspace } from "./launcher";
|
||||
import { attachComposerTestImage, fillComposerDraft } from "./composer";
|
||||
import { fillComposerDraft } from "./composer";
|
||||
import { createAgentTabFromMenu } from "./workspace-tabs";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
startingRefRow,
|
||||
selectWorkspaceIsolation,
|
||||
loadSessionMessageReaders,
|
||||
} from "./new-workspace";
|
||||
@@ -49,43 +44,6 @@ export async function captureWorkspaceAgentRequest(page: Page, options: { block:
|
||||
return { waitForRequest: () => request };
|
||||
}
|
||||
|
||||
/** Hold the creation request before the daemon can reserve or provision anything. */
|
||||
/** Holds each workspace request with an agent until `release` sends the held requests. */
|
||||
export async function holdWorkspaceProvisioning(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
let held: Array<() => void> = [];
|
||||
const requests: WorkspaceCreateRequest[] = [];
|
||||
let agentPrompts = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
const message = frames.client(frame);
|
||||
if (message?.type === "send_agent_message_request") agentPrompts++;
|
||||
if (message?.type === "workspace.create.request" && message.agent) {
|
||||
held.push(() => server.send(frame));
|
||||
requests.push(message);
|
||||
return;
|
||||
}
|
||||
server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
});
|
||||
async function waitForRequests(count: number) {
|
||||
await expect.poll(() => requests.length).toBe(count);
|
||||
return requests[count - 1]!;
|
||||
}
|
||||
return {
|
||||
waitForRequest: () => waitForRequests(1),
|
||||
waitForRequests,
|
||||
release: () => {
|
||||
const sending = held;
|
||||
held = [];
|
||||
for (const send of sending) send();
|
||||
},
|
||||
agentPrompts: () => agentPrompts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pressSubmitBeforeTheNextRender(page: Page, name: string): Promise<void> {
|
||||
const create = page.getByRole("button", { name, exact: true });
|
||||
await expect(create).toBeEnabled();
|
||||
@@ -101,11 +59,9 @@ export async function pressSubmitBeforeTheNextRender(page: Page, name: string):
|
||||
export async function observeCreationRequests(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
const pending = new Set<string>();
|
||||
let workspaceRequests = 0;
|
||||
page.on("websocket", (socket) => {
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
const request = frames.client(payload);
|
||||
if (request?.type === "workspace.create.request") workspaceRequests++;
|
||||
if (
|
||||
request?.type === "workspace.create.request" ||
|
||||
request?.type === "agent.create.request"
|
||||
@@ -128,7 +84,6 @@ export async function observeCreationRequests(page: Page) {
|
||||
});
|
||||
});
|
||||
return {
|
||||
workspaceRequests: () => workspaceRequests,
|
||||
async settled() {
|
||||
await expect.poll(() => pending.size).toBe(0);
|
||||
},
|
||||
@@ -183,101 +138,18 @@ export async function retryNextAgentCreation(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
function agentComposer(page: Page) {
|
||||
// Retained workspaces keep hidden composers mounted beside the focused one.
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true });
|
||||
}
|
||||
|
||||
export async function createCreationScenario(page: Page) {
|
||||
const requests = await observeCreationRequests(page);
|
||||
const project = await seedWorkspace({
|
||||
repoPrefix: "creation-idempotency-",
|
||||
repo: {
|
||||
files: [{ path: "nested/context.txt", content: "Nested agent working directory" }],
|
||||
branches: ["recovery-base"],
|
||||
},
|
||||
});
|
||||
const project = await seedWorkspace({ repoPrefix: "creation-idempotency-" });
|
||||
let workspaceId = project.workspaceId;
|
||||
const evictionWorkspaceIds = new Set<string>();
|
||||
const sourceClients: Array<{ close(): Promise<void> }> = [];
|
||||
return {
|
||||
cleanup: async () => {
|
||||
for (const client of sourceClients) await client.close();
|
||||
await project.cleanup();
|
||||
},
|
||||
async openNestedAgentFork(isolation: "local" | "worktree") {
|
||||
const cwd = `${project.repoPath}/nested`;
|
||||
const sourceClient = await connectNewWorkspaceDaemonClient();
|
||||
sourceClients.push(sourceClient);
|
||||
const source = await sourceClient.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
agent: {
|
||||
config: { provider: "mock", model: "e2e-fast-stream", modeId: "load-test", cwd },
|
||||
initialPrompt: "emit 1 coalesced agent stream updates for the source conversation.",
|
||||
},
|
||||
});
|
||||
if (!source.agent || !source.workspace)
|
||||
throw new Error(source.error ?? "Missing nested source agent");
|
||||
evictionWorkspaceIds.add(source.workspace.id);
|
||||
await project.client.waitForFinish(source.agent.id, 20_000);
|
||||
await gotoWorkspace(page, source.workspace.id);
|
||||
await awaitAssistantMessage(page);
|
||||
await forkMostRecentAssistantTurnToNewWorkspace(page);
|
||||
await selectWorkspaceIsolation(page, isolation);
|
||||
return cwd;
|
||||
},
|
||||
attachImage: () => attachComposerTestImage(page),
|
||||
async expectImageVisible() {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Open image attachment", exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
},
|
||||
async expectInitialPromptOnce(prompt: string) {
|
||||
const agents = (await project.client.fetchAgents()).entries.filter(
|
||||
({ agent }) => agent.workspaceId === workspaceId,
|
||||
);
|
||||
expect(agents).toHaveLength(1);
|
||||
const reader = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
const timeline = await reader.fetchAgentTimeline(agents[0]!.agent.id);
|
||||
expect(timeline.entries.filter(({ item }) => item.type === "user_message")).toHaveLength(1);
|
||||
expect(
|
||||
timeline.entries.filter(
|
||||
({ item }) => item.type === "user_message" && item.text === prompt,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
async expectNestedAgentCwd() {
|
||||
const workspace = (await project.client.fetchWorkspaces()).entries.find(
|
||||
(entry) => entry.id === workspaceId,
|
||||
)!;
|
||||
const nestedAgent = (await project.client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.workspaceId === workspaceId,
|
||||
)!.agent;
|
||||
expect(nestedAgent.cwd).toBe(`${workspace.workspaceDirectory}/nested`);
|
||||
},
|
||||
async closePendingAgent(agentId: string) {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).hover();
|
||||
await page.getByTestId(`workspace-agent-close-${agentId}`).filter({ visible: true }).click();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
},
|
||||
cleanup: project.cleanup,
|
||||
async openWorkspaceForm(isolation: "local" | "worktree") {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, isolation);
|
||||
},
|
||||
async selectWorktreeBranch(name: string) {
|
||||
await openStartingRefPicker(page);
|
||||
await startingRefRow(page, `${name}, local branch`).click();
|
||||
},
|
||||
async openAgentDraft() {
|
||||
await gotoWorkspace(page, project.workspaceId);
|
||||
await createAgentTabFromMenu(page);
|
||||
@@ -293,74 +165,6 @@ export async function createCreationScenario(page: Page) {
|
||||
if (prompt) await fillComposerDraft(page, prompt);
|
||||
await pressSubmitBeforeTheNextRender(page, button);
|
||||
},
|
||||
async expectNormalAgentComposer() {
|
||||
await expect(agentComposer(page)).toBeEditable();
|
||||
// Provisioning belongs to the workspace header and sidebar, not the conversation.
|
||||
await expect(page.getByText("Creating workspace and agent…", { exact: true })).toHaveCount(0);
|
||||
},
|
||||
async expectAgentCannotBeStoppedYet() {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Stop agent" }).filter({ visible: true }),
|
||||
).toBeDisabled();
|
||||
},
|
||||
async queueFollowUp(prompt: string) {
|
||||
await agentComposer(page).fill(prompt);
|
||||
await agentComposer(page).press("Enter");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
},
|
||||
async expectQueuedFollowUp(prompt: string) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Send queued message now" }).filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await expect(page.getByText(prompt, { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
},
|
||||
async resubmitWorkspace() {
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
},
|
||||
async expectAgentConfig(config: { model: string; modeId: string }) {
|
||||
const agent = (await project.client.fetchAgents()).entries.find(
|
||||
(entry) => entry.agent.workspaceId === workspaceId,
|
||||
)!.agent;
|
||||
expect({ model: agent.model, modeId: agent.currentModeId }).toEqual(config);
|
||||
},
|
||||
async typeComposerDraft(text: string) {
|
||||
await agentComposer(page).fill(text);
|
||||
// The composer publishes typed text to its draft after the next paint.
|
||||
await page.evaluate(
|
||||
() => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))),
|
||||
);
|
||||
},
|
||||
async expectComposerDraft(text: string) {
|
||||
await expect(agentComposer(page)).toHaveValue(text);
|
||||
},
|
||||
async expectAgentNotWorking() {
|
||||
await expect(
|
||||
page.getByTestId("turn-working-indicator").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
},
|
||||
async expectTimelineSettled() {
|
||||
await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
|
||||
},
|
||||
async expectUserMessagesInOrder(prompts: string[]) {
|
||||
const agent = (await project.client.fetchAgents()).entries.find(
|
||||
(entry) => entry.agent.workspaceId === workspaceId,
|
||||
);
|
||||
const reader = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await reader.fetchAgentTimeline(agent!.agent.id)).entries.flatMap(({ item }) =>
|
||||
item.type === "user_message" ? [item.text] : [],
|
||||
),
|
||||
)
|
||||
.toEqual(prompts);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
async expectPromptVisible(prompt?: string) {
|
||||
// Inactive agent tabs retain their timeline DOM beside the visible draft.
|
||||
const rows = page.getByTestId("user-message").filter({ visible: true });
|
||||
@@ -371,7 +175,7 @@ export async function createCreationScenario(page: Page) {
|
||||
await expect(page.getByTestId("user-message").first()).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
|
||||
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
},
|
||||
@@ -420,57 +224,15 @@ export async function createCreationScenario(page: Page) {
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectFailedAgentDraft(prompt: string) {
|
||||
await requests.settled();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByText(/Creation startup failed for test/)
|
||||
.filter({ visible: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(agentComposer(page)).toHaveValue(prompt);
|
||||
await this.expectNoCreationRecovery();
|
||||
},
|
||||
async resendFailedDraft() {
|
||||
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||
},
|
||||
async expectReturnedToNewWorkspace(prompt: string, error: string) {
|
||||
await requests.settled();
|
||||
await expect(page).toHaveURL(/\/new\?/);
|
||||
await expect(agentComposer(page)).toHaveValue(prompt);
|
||||
await expect(page.getByText(error).filter({ visible: true }).first()).toBeVisible();
|
||||
await this.expectNoCreationRecovery();
|
||||
expect(requests.workspaceRequests()).toBe(1);
|
||||
expect(
|
||||
(await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
},
|
||||
async expectNoCreationRecovery() {
|
||||
await expect(page.getByRole("button", { name: "Retry creation", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Return to draft", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
async expectStartupFailure() {
|
||||
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
|
||||
},
|
||||
async expectOneCreatedWorkspace() {
|
||||
await expect(page).toHaveURL(/\/workspace\//);
|
||||
await requests.settled();
|
||||
const workspaces = (await project.client.fetchWorkspaces()).entries.filter(
|
||||
(workspace) =>
|
||||
workspace.projectId === project.projectId &&
|
||||
workspace.id !== project.workspaceId &&
|
||||
!evictionWorkspaceIds.has(workspace.id),
|
||||
workspace.projectId === project.projectId && workspace.id !== project.workspaceId,
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
workspaceId = workspaces[0]!.id;
|
||||
@@ -503,11 +265,8 @@ export async function createCreationScenario(page: Page) {
|
||||
.toBe(title);
|
||||
},
|
||||
async evictAndReturnToDraft() {
|
||||
workspaceId = new URL(page.url()).pathname.split("/workspace/")[1]!;
|
||||
const draft = page
|
||||
.locator(
|
||||
'[data-testid^="workspace-tab-draft_"][aria-selected="true"], [data-testid^="workspace-tab-agent_"][aria-selected="true"]',
|
||||
)
|
||||
.locator('[data-testid^="workspace-tab-draft_"][aria-selected="true"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
const draftTestId = await draft.getAttribute("data-testid");
|
||||
@@ -517,7 +276,6 @@ export async function createCreationScenario(page: Page) {
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed eviction workspace");
|
||||
evictionWorkspaceIds.add(result.workspace.id);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
@@ -538,18 +296,15 @@ export async function createCreationScenario(page: Page) {
|
||||
export function createPromptRetryScenario(
|
||||
page: Page,
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
|
||||
kind: "agent" | "workspace" = "agent",
|
||||
) {
|
||||
const requestType = `${kind}.create.request`;
|
||||
const responseType = `${kind}.create.response`;
|
||||
let firstMessage: ReturnType<typeof gate.getClientRequests>[number] | undefined;
|
||||
return {
|
||||
holdAcknowledgement() {
|
||||
gate.holdNextServerMessage(responseType);
|
||||
gate.holdNextServerMessage("agent.create.response");
|
||||
},
|
||||
async waitForDeliveredPrompt() {
|
||||
await gate.waitForHeldServerMessage(responseType);
|
||||
firstMessage = gate.getClientRequests(requestType).at(-1);
|
||||
await gate.waitForHeldServerMessage("agent.create.response");
|
||||
firstMessage = gate.getClientRequests("agent.create.request").at(-1);
|
||||
},
|
||||
async disconnectAndReconnect() {
|
||||
const fetches = gate.getClientRequestCount("fetch_agents_request");
|
||||
@@ -560,89 +315,13 @@ export function createPromptRetryScenario(
|
||||
.toBeGreaterThan(fetches);
|
||||
},
|
||||
async expectSameAgentAndMessage() {
|
||||
expect(gate.getClientRequestCount(requestType)).toBe(1);
|
||||
expect(gate.getClientRequestCount("agent.create.request")).toBe(1);
|
||||
await expect
|
||||
.poll(() => gate.getClientRequestCount("creation.subscribe.request"))
|
||||
.toBeGreaterThan(0);
|
||||
expect(gate.getClientRequests(requestType).at(-1)).toEqual(firstMessage);
|
||||
expect(gate.getClientRequests("agent.create.request").at(-1)).toEqual(firstMessage);
|
||||
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(0);
|
||||
await expect(page.getByTestId("user-message").filter({ visible: true })).toHaveCount(1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function hideCreationLifecycle(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
let requests = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
if (frames.client(frame)?.type === "workspace.create.request") requests++;
|
||||
server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => {
|
||||
const message = frames.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "server_info" &&
|
||||
typeof message.payload.features === "object" &&
|
||||
message.payload.features !== null
|
||||
) {
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
features: { ...message.payload.features, creationLifecycle: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
return { expectNoCreationRequest: () => expect(requests).toBe(0) };
|
||||
}
|
||||
|
||||
/** Simulate an uncertain receipt after the real daemon registered and ran the initial agent. */
|
||||
export async function reportUnknownPromptOutcome(page: Page) {
|
||||
const frames = await loadSessionMessageReaders();
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => server.send(frame));
|
||||
server.onMessage((frame) => {
|
||||
const message = frames.server(frame);
|
||||
if (
|
||||
message?.type === "workspace.create.response" &&
|
||||
message.payload.agent &&
|
||||
message.payload.creation
|
||||
) {
|
||||
const error = "Initial prompt outcome is uncertain for test";
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
...JSON.parse(frame.toString()),
|
||||
message: {
|
||||
...message,
|
||||
payload: {
|
||||
...message.payload,
|
||||
error,
|
||||
creation: {
|
||||
...message.payload.creation,
|
||||
phase: "failed",
|
||||
error,
|
||||
outcomeUnknown: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "fetchAgents"
|
||||
| "fetchAgentTimeline"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
|
||||
@@ -42,7 +42,6 @@ type HostSection =
|
||||
| "pair-device"
|
||||
| "agents"
|
||||
| "metadata"
|
||||
| "voice"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
| "usage"
|
||||
@@ -58,92 +57,6 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
|
||||
await expectAppRoute(page, buildSettingsSectionRoute(section));
|
||||
}
|
||||
|
||||
export async function revealSettingsSidebarSection(
|
||||
page: Page,
|
||||
section: SettingsSection,
|
||||
): Promise<number> {
|
||||
await page
|
||||
.getByRole("navigation", { name: "Settings" })
|
||||
.getByRole("button", { name: SECTION_LABELS[section], exact: true })
|
||||
.scrollIntoViewIfNeeded();
|
||||
const offset = await page
|
||||
.getByTestId("settings-sidebar-scroll-body")
|
||||
.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarScrollSettled(
|
||||
page: Page,
|
||||
offset: number,
|
||||
): Promise<void> {
|
||||
// RN Web emits its final scroll callback 100 ms after scrolling stops.
|
||||
await page.waitForTimeout(200);
|
||||
await expectSettingsSidebarScrollOffset(page, offset);
|
||||
}
|
||||
|
||||
export async function selectSettingsHostSectionsRapidly(page: Page): Promise<void> {
|
||||
for (let selection = 0; selection < 5; selection += 1) {
|
||||
await page
|
||||
.getByRole("navigation", { name: "Settings" })
|
||||
.getByRole("button", { name: "Providers", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Settings" })
|
||||
.getByRole("button", { name: "Terminals", exact: true })
|
||||
.click();
|
||||
await expectSettingsHeader(page, "Terminals");
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarUp(page: Page, pixels: number): Promise<void> {
|
||||
await page.getByTestId("settings-sidebar-scroll-body").hover();
|
||||
await page.mouse.wheel(0, -pixels);
|
||||
}
|
||||
|
||||
export async function changeSettingsInterfaceFontSize(page: Page, size: number): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Interface font size" });
|
||||
await input.fill(String(size));
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveValue(String(size));
|
||||
}
|
||||
|
||||
export async function scrollSettingsSidebarToEnd(page: Page): Promise<number> {
|
||||
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
|
||||
await scrollBody.hover();
|
||||
await page.mouse.wheel(0, 2000);
|
||||
await expect
|
||||
.poll(() =>
|
||||
scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight - node.scrollTop),
|
||||
)
|
||||
.toBe(0);
|
||||
const offset = await scrollBody.evaluate((node) => node.scrollTop);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarScrollOffset(page: Page, offset: number): Promise<void> {
|
||||
const scrollBody = page.locator('[data-testid="settings-sidebar-scroll-body"]:visible');
|
||||
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
|
||||
}
|
||||
|
||||
export async function scrollSettingsDetailDown(page: Page): Promise<number> {
|
||||
const scrollBody = page.getByTestId("settings-detail-scroll-body");
|
||||
await expect
|
||||
.poll(() => scrollBody.evaluate((node) => node.scrollHeight - node.clientHeight))
|
||||
.toBeGreaterThan(0);
|
||||
await scrollBody.hover();
|
||||
await page.mouse.wheel(0, 1000);
|
||||
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
|
||||
const offset = await scrollBody.evaluate((node) => node.scrollTop);
|
||||
return offset;
|
||||
}
|
||||
|
||||
export async function expectSettingsDetailScrollOffset(page: Page, offset: number): Promise<void> {
|
||||
const scrollBody = page.getByTestId("settings-detail-scroll-body");
|
||||
await expect.poll(() => scrollBody.evaluate((node) => node.scrollTop)).toBe(offset);
|
||||
}
|
||||
|
||||
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
// Host sections are now flat top-level rows under the Host group. Navigate by
|
||||
// clicking the Connections section row; the picker only matters when >1 host.
|
||||
|
||||
@@ -188,7 +188,7 @@ export async function expectManagedSubagentArchived(
|
||||
}
|
||||
|
||||
export async function archiveFinishedSubagents(page: Page): Promise<void> {
|
||||
await page.getByRole("menuitem", { name: "Archive finished subagents" }).click();
|
||||
await page.getByRole("button", { name: "Archive finished subagents" }).click();
|
||||
}
|
||||
|
||||
export async function expectArchiveFinishedInProgress(
|
||||
@@ -196,7 +196,7 @@ export async function expectArchiveFinishedInProgress(
|
||||
completed: number,
|
||||
total: number,
|
||||
): Promise<void> {
|
||||
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
|
||||
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export async function expectArchiveFinishedRetry(
|
||||
total: number,
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Archive finished subagents" })).toBeEnabled();
|
||||
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function holdManagedSubagentArchiveRequest(
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { rename, mkdir, rmdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectSettingsHeader, openHostSection, openSettingsHost } from "./settings";
|
||||
|
||||
export type SpeechSiteLabel = "Speech-to-text" | "Listening" | "Speaking";
|
||||
type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts";
|
||||
|
||||
export async function openVoiceSettings(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "voice");
|
||||
await expectSettingsHeader(page, "Voice");
|
||||
}
|
||||
|
||||
function speechSiteRow(page: Page, label: SpeechSiteLabel): Locator {
|
||||
return page.getByRole("button", { name: new RegExp(`^${label}, `) });
|
||||
}
|
||||
|
||||
export async function expectSpeechSiteSummary(
|
||||
page: Page,
|
||||
label: SpeechSiteLabel,
|
||||
summary: string,
|
||||
): Promise<void> {
|
||||
await expect(speechSiteRow(page, label)).toHaveAccessibleName(`${label}, ${summary}`);
|
||||
}
|
||||
|
||||
/** Opens the provider sheet for one use site and returns it. */
|
||||
export async function openSpeechSite(page: Page, label: SpeechSiteLabel): Promise<Locator> {
|
||||
await speechSiteRow(page, label).click();
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet.getByText(label, { exact: true })).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export function providerOption(sheet: Locator, provider: "Local" | "OpenAI"): Locator {
|
||||
return sheet.getByRole("button", { name: provider, exact: true });
|
||||
}
|
||||
|
||||
export function apiKeyInput(sheet: Locator): Locator {
|
||||
return sheet.getByRole("textbox", { name: "API key" });
|
||||
}
|
||||
|
||||
export function baseUrlInput(sheet: Locator): Locator {
|
||||
return sheet.getByRole("textbox", { name: "Base URL" });
|
||||
}
|
||||
|
||||
export async function saveSpeechSite(sheet: Locator): Promise<void> {
|
||||
await sheet.getByRole("button", { name: "Save", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectSheetClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** The speech site as the daemon reports it; the API key itself never leaves the daemon. */
|
||||
export async function readSpeechSite(siteId: SpeechSiteId): Promise<Record<string, unknown>> {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
try {
|
||||
const { config } = await client.getDaemonConfig();
|
||||
const features = config.features as Record<string, Record<string, unknown>>;
|
||||
const [feature, site] = siteId.split(".");
|
||||
return features[feature][site] as Record<string, unknown>;
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts a speech site back to the default provider so later tests start from a known state. */
|
||||
export async function resetSpeechSite(siteId: SpeechSiteId): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
try {
|
||||
const [feature, site] = siteId.split(".");
|
||||
await client.patchDaemonConfig({ features: { [feature]: { [site]: { provider: "local" } } } });
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes the daemon's config file unreadable for the duration of `run`. */
|
||||
export async function withUnwritableDaemonConfig(run: () => Promise<void>): Promise<void> {
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is required to break the daemon config");
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
const asidePath = `${configPath}.aside`;
|
||||
await rename(configPath, asidePath);
|
||||
await mkdir(configPath);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
await rmdir(configPath);
|
||||
await rename(asidePath, configPath);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -61,7 +61,6 @@
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@marijn/find-cluster-break": "^1.0.2",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "0.3.2",
|
||||
"@react-native/normalize-colors": "0.86.3",
|
||||
@@ -127,14 +126,14 @@
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-keyboard-controller": "1.21.12",
|
||||
"react-native-nitro-modules": "0.35.5",
|
||||
"react-native-reanimated": "4.5.5",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "13.16.1",
|
||||
"react-native-worklets": "0.10.4",
|
||||
"react-native-worklets": "0.10.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2",
|
||||
@@ -199,14 +198,6 @@
|
||||
],
|
||||
"enableVideo": false
|
||||
},
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native-reanimated",
|
||||
"react-native-worklets"
|
||||
]
|
||||
}
|
||||
},
|
||||
"reanimated": {
|
||||
"staticFeatureFlags": {
|
||||
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useAgentCreation } from "@/workspace-creation";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
@@ -34,7 +33,6 @@ export function useAgentDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const creation = useAgentCreation(context.serverId, target.agentId);
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[context.serverId];
|
||||
@@ -51,10 +49,8 @@ export function useAgentDescriptor(
|
||||
};
|
||||
}),
|
||||
);
|
||||
const provider = creation ? creation.provider : descriptorState.provider;
|
||||
const title = creation ? creation.text : descriptorState.title;
|
||||
const icon = useProviderIcon(provider, context.serverId);
|
||||
return buildAgentDescriptor({ ...descriptorState, provider, title, icon });
|
||||
const icon = useProviderIcon(descriptorState.provider, context.serverId);
|
||||
return buildAgentDescriptor({ ...descriptorState, icon });
|
||||
}
|
||||
|
||||
export function buildAgentDescriptor(input: {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
// The view retains its last visible transcript. Selecting a stable value here
|
||||
// also prevents incoming chunks from scheduling React work in a hidden panel.
|
||||
// Revealing the panel reads the current tail directly from the model.
|
||||
export function useAgentStreamTail(serverId: string, agentId: string | undefined) {
|
||||
const active = useRetainedPanelActive();
|
||||
return useSessionStore((state) =>
|
||||
active && agentId ? state.sessions[serverId]?.agentStreamTail.get(agentId) : undefined,
|
||||
);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { useSettingsRouteEntry } from "@/navigation/settings-presentation";
|
||||
import { useSettingsRouteEntry } from "@/navigation/settings-selection";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useFetchQuery } from "@/data/query";
|
||||
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
|
||||
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
@@ -88,7 +87,11 @@ function HostAgentReadyRouteContent() {
|
||||
]);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const hasHydratedLayout = useWorkspaceLayoutStoreHydrated();
|
||||
useDirectoryDemand(serverId);
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
// A compact app can open this route before its sidebar has demanded the directory.
|
||||
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
|
||||
}, [serverId]);
|
||||
const resolution = resolveAgentRoute({
|
||||
hasHydratedWorkspaces,
|
||||
hasHydratedLayout,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import { useDirectoryDemand } from "@/hooks/use-directory-demand";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
@@ -111,10 +110,6 @@ function HostWorkspaceRouteContent() {
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(params.open);
|
||||
// A restored workspace with no open chat has nothing else demanding its host's directory on a
|
||||
// phone, where the sidebar only demands while its panel is active. Without demand the deck
|
||||
// waits on workspace hydration that never starts.
|
||||
useDirectoryDemand(serverId);
|
||||
useEffect(() => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
|
||||
@@ -112,9 +112,8 @@ const ContextWindowRing = withUnistyles(function ContextWindowRing({
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
{...(isNative
|
||||
? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" }
|
||||
: { "aria-hidden": true })}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
|
||||
@@ -77,7 +77,6 @@ import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-dra
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useOpenDirectoryInEditor } from "@/workspace/open-in-editor/directory";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
@@ -451,15 +450,9 @@ export function FileExplorerPane({
|
||||
isLocalExecution: isLocalDaemon,
|
||||
});
|
||||
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
|
||||
const projectId = useWorkspaceFields(
|
||||
serverId,
|
||||
workspaceId ?? null,
|
||||
(workspace) => workspace.projectId,
|
||||
);
|
||||
const openDirectoryInEditor = useOpenDirectoryInEditor({
|
||||
serverId,
|
||||
workspaceDirectory: normalizedWorkspaceRoot,
|
||||
projectId,
|
||||
});
|
||||
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
|
||||
const fsEntryOpsEnabled = useSessionStore(
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { NestedSettingsRows } from "./row-density";
|
||||
import { SettingsRowHeader } from "./row-header";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedChevron = withUnistyles(ChevronRight);
|
||||
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
interface SettingsCollapsibleRowProps {
|
||||
label: string;
|
||||
/** A short status shown before the chevron, so the row says something while collapsed. */
|
||||
summary?: string;
|
||||
defaultExpanded?: boolean;
|
||||
testID?: string;
|
||||
/** The rows revealed underneath, separated like the rows of a `SettingsCard` and laid out as nested rows. */
|
||||
/** The rows revealed underneath, separated like the rows of a `SettingsCard`. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -23,7 +25,6 @@ interface SettingsCollapsibleRowProps {
|
||||
*/
|
||||
export function SettingsCollapsibleRow({
|
||||
label,
|
||||
summary,
|
||||
defaultExpanded = false,
|
||||
testID,
|
||||
children,
|
||||
@@ -36,39 +37,66 @@ export function SettingsCollapsibleRow({
|
||||
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
|
||||
[expanded],
|
||||
);
|
||||
const headerStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
hovered && styles.headerHovered,
|
||||
pressed && styles.headerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, expanded && styles.chevronExpanded],
|
||||
[expanded],
|
||||
);
|
||||
|
||||
return (
|
||||
<View testID={testID}>
|
||||
<SettingsRowHeader
|
||||
label={label}
|
||||
summary={summary}
|
||||
onPress={toggle}
|
||||
expanded={expanded}
|
||||
<Pressable
|
||||
{...webExpandedProps}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={accessibilityState}
|
||||
webAccessibilityProps={webExpandedProps}
|
||||
onPress={toggle}
|
||||
style={headerStyle}
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
/>
|
||||
{expanded ? (
|
||||
<View style={styles.children}>
|
||||
<NestedSettingsRows>
|
||||
{Children.toArray(children).map((child, index) => (
|
||||
<View
|
||||
key={isValidElement(child) ? child.key : index}
|
||||
style={settingsStyles.rowBorder}
|
||||
>
|
||||
{child}
|
||||
</View>
|
||||
))}
|
||||
</NestedSettingsRows>
|
||||
</View>
|
||||
) : null}
|
||||
>
|
||||
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
||||
<>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
</View>
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
{expanded
|
||||
? Children.toArray(children).map((child, index) => (
|
||||
<View key={isValidElement(child) ? child.key : index} style={settingsStyles.rowBorder}>
|
||||
{child}
|
||||
</View>
|
||||
))
|
||||
: null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The revealed rows keep the card's rails; the tint is what groups them under their header.
|
||||
children: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
headerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
headerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
// Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this
|
||||
// puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end.
|
||||
chevron: { marginRight: -theme.spacing[1] },
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { SettingsRowHeader } from "./row-header";
|
||||
|
||||
interface SettingsDetailRowProps {
|
||||
label: string;
|
||||
/** What the detail is set to, so the row says it without being opened. */
|
||||
summary?: string;
|
||||
onPress: () => void;
|
||||
/** A row that cannot be opened keeps its summary and drops the chevron. */
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settings row that opens a detail, such as a sheet, for one setting. The label, a one-line
|
||||
* summary of the current value and a chevron pointing right. Sits inside a `SettingsCard`.
|
||||
*/
|
||||
export function SettingsDetailRow({
|
||||
label,
|
||||
summary,
|
||||
onPress,
|
||||
disabled,
|
||||
testID,
|
||||
}: SettingsDetailRowProps) {
|
||||
return (
|
||||
<SettingsRowHeader
|
||||
label={label}
|
||||
summary={summary}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Info } from "lucide-react-native";
|
||||
@@ -6,13 +6,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
type TipIconComponent = ComponentType<{ size: number; color: string }>;
|
||||
|
||||
function TipIcon({ icon: Icon, color }: { icon: TipIconComponent; color: string }) {
|
||||
return <Icon size={ICON_SIZE.sm} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedTipIcon = withUnistyles(TipIcon);
|
||||
const ThemedInfo = withUnistyles(Info);
|
||||
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -20,37 +14,27 @@ interface SettingsInfoTipProps {
|
||||
/** Header the tip belongs to; used for the accessibility label. */
|
||||
title: string;
|
||||
info: ReactNode;
|
||||
/** Defaults to the info icon. */
|
||||
icon?: TipIconComponent;
|
||||
/** Defaults to "About {title}". */
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one way a settings group or section explains itself: an info icon on the
|
||||
* header that opens a tooltip. Never a paragraph under the header — see
|
||||
* docs/design.md §7. Rows use it the same way to keep secondary details on one line.
|
||||
* docs/design.md §7.
|
||||
*/
|
||||
export function SettingsInfoTip({
|
||||
title,
|
||||
info,
|
||||
icon = Info,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: SettingsInfoTipProps) {
|
||||
export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? t("settings.groupInfo", { title })}
|
||||
accessibilityLabel={t("settings.groupInfo", { title })}
|
||||
testID={testID}
|
||||
hitSlop={8}
|
||||
style={styles.button}
|
||||
>
|
||||
<ThemedTipIcon icon={icon} uniProps={mutedIconMapping} />
|
||||
<ThemedInfo size={ICON_SIZE.sm} uniProps={mutedIconMapping} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" offset={8}>
|
||||
|
||||
@@ -24,21 +24,13 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/component
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ControlSizeProvider } from "@/components/ui/control-size";
|
||||
import { useSettingsRowLayout } from "./row-density";
|
||||
export { SettingsGroup } from "./headings/settings-group";
|
||||
export { SettingsSection } from "./headings/settings-section";
|
||||
export { SettingsCollapsibleRow } from "./collapsible-row";
|
||||
export { SettingsDetailRow } from "./detail-row";
|
||||
export { SettingsInfoTip } from "./headings/settings-info-tip";
|
||||
|
||||
/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */
|
||||
interface AppRowExtras {
|
||||
labelAccessory?: ReactNode;
|
||||
}
|
||||
|
||||
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint">, AppRowExtras {
|
||||
interface AppSettingsRowProps extends Omit<SettingsRowProps, "hint"> {
|
||||
hint?: ReactNode;
|
||||
labelAccessory?: ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) {
|
||||
@@ -65,32 +57,26 @@ export function SettingsRow({
|
||||
testID,
|
||||
}: AppSettingsRowProps) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const layout = useSettingsRowLayout();
|
||||
const rowStyle = useMemo(() => [layout.row, compact && styles.compactRow], [compact, layout]);
|
||||
const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]);
|
||||
const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
|
||||
return (
|
||||
<View style={rowStyle} testID={testID}>
|
||||
<View style={styles.label}>
|
||||
{labelAccessory ? (
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={accessoryTitleStyle}>{label}</Text>
|
||||
<Text style={[settingsStyles.rowTitle, styles.accessoryLabel]}>{label}</Text>
|
||||
{labelAccessory}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
)}
|
||||
{typeof hint === "string" ? <Text style={layout.hint}>{hint}</Text> : hint}
|
||||
{typeof hint === "string" ? <Text style={settingsStyles.rowHint}>{hint}</Text> : hint}
|
||||
{error ? (
|
||||
<Text accessibilityRole="alert" style={settingsStyles.rowError}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{children ? (
|
||||
<View style={styles.control}>
|
||||
<ControlSizeProvider size={layout.controlSize}>{children}</ControlSizeProvider>
|
||||
</View>
|
||||
) : null}
|
||||
{children ? <View style={styles.control}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -131,7 +117,7 @@ export function SettingsSelect<Value extends string>({
|
||||
onValueChange,
|
||||
disabled,
|
||||
...row
|
||||
}: SettingsSelectProps<Value> & AppRowExtras) {
|
||||
}: SettingsSelectProps<Value>) {
|
||||
const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
@@ -166,7 +152,7 @@ export function SettingsInput({
|
||||
secureTextEntry,
|
||||
ref,
|
||||
...row
|
||||
}: SettingsInputProps & AppRowExtras) {
|
||||
}: SettingsInputProps) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const input = useRef<EditingTextInputHandle>(null);
|
||||
useImperativeHandle(
|
||||
@@ -189,7 +175,7 @@ export function SettingsInput({
|
||||
editable={!disabled}
|
||||
secureTextEntry={secureTextEntry}
|
||||
accessibilityLabel={row.label}
|
||||
size={compact ? "md" : undefined}
|
||||
size={compact ? "md" : "sm"}
|
||||
style={styles.input}
|
||||
/>
|
||||
</SettingsRow>
|
||||
@@ -199,7 +185,7 @@ export function SettingsInput({
|
||||
export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) {
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<Button variant="outline" onPress={onPress} disabled={disabled}>
|
||||
<Button variant="outline" size="sm" onPress={onPress} disabled={disabled}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings";
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
function mountRows() {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() =>
|
||||
root?.render(
|
||||
<SettingsCard>
|
||||
<SettingsRow label="Card row" testID="card-row">
|
||||
<Button variant="outline" testID="card-button">
|
||||
Download
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<SettingsCollapsibleRow label="Group" defaultExpanded testID="group">
|
||||
<SettingsRow label="Nested with a button" testID="nested-with-button">
|
||||
<Button variant="outline" testID="nested-button">
|
||||
Download
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Nested without a button" testID="nested-without-button" />
|
||||
</SettingsCollapsibleRow>
|
||||
</SettingsCard>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function element(testID: string): HTMLElement {
|
||||
const found = document.querySelector(`[data-testid="${testID}"]`);
|
||||
if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function heightOf(testID: string): number {
|
||||
return element(testID).getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
function titleFontSize(rowTestID: string, label: string): number {
|
||||
const title = [...element(rowTestID).querySelectorAll("div")].find(
|
||||
(node) => node.textContent === label && node.children.length === 0,
|
||||
);
|
||||
if (!title) throw new Error(`No title ${label}`);
|
||||
return Number.parseFloat(getComputedStyle(title).fontSize);
|
||||
}
|
||||
|
||||
describe("settings row density", () => {
|
||||
it("lines up nested rows with and without a button, tighter than card rows", () => {
|
||||
mountRows();
|
||||
|
||||
expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button"));
|
||||
expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row"));
|
||||
expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button"));
|
||||
});
|
||||
|
||||
it("sizes nested text and controls one step below card rows", () => {
|
||||
mountRows();
|
||||
|
||||
expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button"));
|
||||
expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan(
|
||||
titleFontSize("card-row", "Card row"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
|
||||
import type { ContainedControlSize } from "@/components/ui/control-size";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows
|
||||
// of the card: tighter padding, smaller text, smaller controls. The container decides; a row
|
||||
// never knows on its own that it is nested.
|
||||
const NestedRowsContext = createContext(false);
|
||||
|
||||
export function NestedSettingsRows({ children }: { children: ReactNode }) {
|
||||
return <NestedRowsContext.Provider value>{children}</NestedRowsContext.Provider>;
|
||||
}
|
||||
|
||||
export interface SettingsRowLayout {
|
||||
row: StyleProp<ViewStyle>;
|
||||
title: StyleProp<TextStyle>;
|
||||
hint: StyleProp<TextStyle>;
|
||||
/** The size of the controls in the row's trailing slot. */
|
||||
controlSize: ContainedControlSize;
|
||||
}
|
||||
|
||||
/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */
|
||||
export function useSettingsRowLayout(): SettingsRowLayout {
|
||||
const nested = useContext(NestedRowsContext);
|
||||
return useMemo(
|
||||
() =>
|
||||
nested
|
||||
? {
|
||||
row: [settingsStyles.row, styles.nestedRow],
|
||||
title: [settingsStyles.rowTitle, styles.nestedText],
|
||||
hint: [settingsStyles.rowHint, styles.nestedText],
|
||||
controlSize: "xs",
|
||||
}
|
||||
: {
|
||||
row: settingsStyles.row,
|
||||
title: settingsStyles.rowTitle,
|
||||
hint: settingsStyles.rowHint,
|
||||
controlSize: "sm",
|
||||
},
|
||||
[nested],
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Every nested row is as tall as one holding an `xs` control, so rows with and without a
|
||||
// trailing button line up.
|
||||
nestedRow: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2,
|
||||
},
|
||||
nestedText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -1,113 +0,0 @@
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { useSettingsRowLayout } from "./row-density";
|
||||
|
||||
const ThemedChevron = withUnistyles(ChevronRight);
|
||||
const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
type HoverState = PressableStateCallbackType & { hovered?: boolean };
|
||||
|
||||
export interface SettingsRowHeaderProps {
|
||||
label: string;
|
||||
/** A short status shown before the chevron. */
|
||||
summary?: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
/** Turns the chevron down, for a row whose children are revealed in place. */
|
||||
expanded?: boolean;
|
||||
accessibilityState?: AccessibilityState;
|
||||
/** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */
|
||||
webAccessibilityProps?: Record<string, unknown> | null;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pressable line shared by settings rows that lead somewhere: a label, an optional summary and
|
||||
* a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it
|
||||
* pointing right.
|
||||
*/
|
||||
export function SettingsRowHeader({
|
||||
label,
|
||||
summary,
|
||||
onPress,
|
||||
disabled,
|
||||
expanded = false,
|
||||
accessibilityState,
|
||||
webAccessibilityProps,
|
||||
testID,
|
||||
}: SettingsRowHeaderProps) {
|
||||
const layout = useSettingsRowLayout();
|
||||
const rowStyle = useCallback(
|
||||
({ hovered, pressed }: HoverState) => [
|
||||
layout.row,
|
||||
!disabled && hovered && styles.hovered,
|
||||
!disabled && pressed && styles.pressed,
|
||||
],
|
||||
[disabled, layout],
|
||||
);
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, expanded && styles.chevronExpanded],
|
||||
[expanded],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
{...webAccessibilityProps}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={summary ? `${label}, ${summary}` : label}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
highlightStyle={styles.pressed}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered }: HoverState) => (
|
||||
<>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={layout.title}>{label}</Text>
|
||||
</View>
|
||||
{summary ? (
|
||||
<Text style={styles.summary} numberOfLines={1}>
|
||||
{summary}
|
||||
</Text>
|
||||
) : null}
|
||||
{disabled ? null : (
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={hovered ? hoveredIconColorMapping : mutedIconColorMapping}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
summary: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
hovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
pressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
// Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this
|
||||
// puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end.
|
||||
chevron: { marginRight: -theme.spacing[1] },
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useWorkspaceCreation } from "@/workspace-creation";
|
||||
import { CreatingWorkspaceRow } from "@/workspace-creation/ui";
|
||||
import { memo, useCallback, useMemo, useState, type Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, type GestureResponderEvent } from "react-native";
|
||||
@@ -60,16 +58,7 @@ interface SidebarWorkspaceRowProps {
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceRow(props: SidebarWorkspaceRowProps) {
|
||||
const creation = useWorkspaceCreation(props.workspace.serverId, props.workspace.workspaceId);
|
||||
if (creation && !creation.workspaceObserved)
|
||||
return (
|
||||
<CreatingWorkspaceRow attempt={creation} onPress={props.onPress} selected={props.selected} />
|
||||
);
|
||||
return <ReadySidebarWorkspaceRow {...props} />;
|
||||
}
|
||||
|
||||
function ReadySidebarWorkspaceRow({
|
||||
export function SidebarWorkspaceRow({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { View, useWindowDimensions } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
@@ -32,8 +32,7 @@ interface RelativeAnchorRect {
|
||||
|
||||
interface AutocompletePopoverProps {
|
||||
visible: boolean;
|
||||
children: ReactNode;
|
||||
anchorStyle?: StyleProp<ViewStyle>;
|
||||
anchorRef: RefObject<View | null>;
|
||||
options: readonly AutocompleteOption[];
|
||||
selectedIndex: number;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
@@ -45,8 +44,7 @@ interface AutocompletePopoverProps {
|
||||
|
||||
export function AutocompletePopover({
|
||||
visible,
|
||||
children,
|
||||
anchorStyle,
|
||||
anchorRef,
|
||||
options,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
@@ -57,7 +55,6 @@ export function AutocompletePopover({
|
||||
}: AutocompletePopoverProps): ReactElement | null {
|
||||
"use no memo";
|
||||
// React Compiler memoizes effect captures by reading SharedValue.value during render.
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [relativeAnchorRect, setRelativeAnchorRect] = useState<RelativeAnchorRect | null>(null);
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
@@ -140,29 +137,25 @@ export function AutocompletePopover({
|
||||
[anchorY, reservedLift, safeAreaInsets.top],
|
||||
);
|
||||
|
||||
const showPopover = canMeasure && relativeAnchorRect && baseStyle;
|
||||
if (!visible || !relativeAnchorRect || !baseStyle) return null;
|
||||
if (options.length > 0 && selectedIndex < 0) return null;
|
||||
|
||||
// The translate view is the popover's own frame, so it lifts only the popover.
|
||||
return (
|
||||
<View ref={anchorRef} onLayout={remeasure} style={anchorStyle} collapsable={false}>
|
||||
{children}
|
||||
{showPopover ? (
|
||||
<Portal hostName={portalHostName}>
|
||||
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
isLoading={isLoading}
|
||||
errorMessage={errorMessage}
|
||||
loadingText={loadingText}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
</Animated.View>
|
||||
</KeyboardTranslateView>
|
||||
</Portal>
|
||||
) : null}
|
||||
</View>
|
||||
<Portal hostName={portalHostName}>
|
||||
<KeyboardTranslateView {...anchor.translation} style={baseStyle}>
|
||||
<Animated.View testID="composer-autocomplete-popover" style={heightStyle}>
|
||||
<Autocomplete
|
||||
options={options}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={onSelect}
|
||||
isLoading={isLoading}
|
||||
errorMessage={errorMessage}
|
||||
loadingText={loadingText}
|
||||
emptyText={emptyText}
|
||||
/>
|
||||
</Animated.View>
|
||||
</KeyboardTranslateView>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -247,16 +247,14 @@ export function Autocomplete({
|
||||
<View style={styles.outerWrapper}>
|
||||
{selectedOption?.kind === "command" && selectedOption.description ? (
|
||||
<View style={styles.detailCard}>
|
||||
<Text style={styles.detailLabel} numberOfLines={1}>
|
||||
<Text style={styles.detailLabel}>
|
||||
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
|
||||
</Text>
|
||||
<Text style={styles.detailDescription} numberOfLines={2} ellipsizeMode="tail">
|
||||
<Text style={styles.detailDescription}>
|
||||
{removeBoltGlyphs(selectedOption.description)}
|
||||
</Text>
|
||||
{selectedOption.detail ? (
|
||||
<Text style={styles.detailHint} numberOfLines={1}>
|
||||
{removeBoltGlyphs(selectedOption.detail)}
|
||||
</Text>
|
||||
<Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
@@ -295,9 +293,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
detailCard: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
@@ -323,8 +318,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
},
|
||||
container: {
|
||||
flexShrink: 1,
|
||||
// Keep a complete selectable row (36), list padding (8), and borders (2).
|
||||
minHeight: 46,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useContainedControlSize } from "@/components/ui/control-size";
|
||||
import {
|
||||
default as React,
|
||||
useCallback,
|
||||
@@ -163,7 +162,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
export function Button({
|
||||
children,
|
||||
variant = "secondary",
|
||||
size: sizeProp,
|
||||
size = "md",
|
||||
leftIcon,
|
||||
trailing,
|
||||
style,
|
||||
@@ -186,8 +185,6 @@ export function Button({
|
||||
}
|
||||
>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const containedSize = useContainedControlSize();
|
||||
const size = sizeProp ?? containedSize ?? "md";
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
let variantStyle: ViewStyle;
|
||||
|
||||
@@ -81,8 +81,6 @@ describe("control geometry", () => {
|
||||
it("keeps field text sizing tied to control size", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
expect(geometry.fieldTextXs.fontSize).toBe(12);
|
||||
expect(geometry.fieldTextXs.lineHeight).toBe(17);
|
||||
expect(geometry.fieldTextSm.fontSize).toBe(14);
|
||||
expect(geometry.fieldTextSm.lineHeight).toBe(20);
|
||||
expect(geometry.fieldTextMd.fontSize).toBe(14);
|
||||
@@ -97,13 +95,6 @@ describe("control geometry", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
const borderWidth = theme.borderWidth[1];
|
||||
|
||||
expect(geometry.fieldControlXs.minHeight).toBe(28);
|
||||
expect(
|
||||
geometry.fieldTextXs.lineHeight +
|
||||
geometry.fieldControlXs.paddingVertical * 2 +
|
||||
borderWidth * 2,
|
||||
).toBe(geometry.fieldControlXs.minHeight);
|
||||
|
||||
expect(geometry.fieldControlSm.minHeight).toBe(32);
|
||||
expect(geometry.fieldControlSm.paddingVertical).toBe(5);
|
||||
expect(
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
||||
export type FieldControlSize = "xs" | "sm" | "md";
|
||||
export type FieldControlSize = "sm" | "md";
|
||||
export type SegmentedControlSize = "xs" | "sm" | "md";
|
||||
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
||||
|
||||
@@ -188,19 +188,8 @@ export function resolveControlInteractionStyles(
|
||||
|
||||
export function createControlGeometry(theme: Theme) {
|
||||
const controlBorderWidth = theme.borderWidth[1];
|
||||
const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm);
|
||||
const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||
const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base);
|
||||
const fieldControlXs = {
|
||||
minHeight: CONTROL_HEIGHTS.tight,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: fieldVerticalPadding(
|
||||
CONTROL_HEIGHTS.tight,
|
||||
fieldTextXsLineHeight,
|
||||
controlBorderWidth,
|
||||
),
|
||||
borderRadius: theme.borderRadius.md,
|
||||
};
|
||||
const fieldControlSm = {
|
||||
minHeight: CONTROL_HEIGHTS.compact,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -221,10 +210,6 @@ export function createControlGeometry(theme: Theme) {
|
||||
),
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
};
|
||||
const fieldTextXs = {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: fieldTextXsLineHeight,
|
||||
};
|
||||
const fieldTextSm = {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: fieldTextSmLineHeight,
|
||||
@@ -267,10 +252,6 @@ export function createControlGeometry(theme: Theme) {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
alert,
|
||||
formTextInputXs: {
|
||||
...fieldControlXs,
|
||||
...fieldTextXs,
|
||||
},
|
||||
formTextInputSm: {
|
||||
...fieldControlSm,
|
||||
...fieldTextSm,
|
||||
@@ -283,10 +264,8 @@ export function createControlGeometry(theme: Theme) {
|
||||
...fieldControlMd,
|
||||
...fieldTextMd,
|
||||
},
|
||||
fieldControlXs,
|
||||
fieldControlSm,
|
||||
fieldControlMd,
|
||||
fieldTextXs,
|
||||
fieldTextSm,
|
||||
fieldTextMd,
|
||||
controlRest: {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
/** The control sizes a container can ask of the controls placed in it. */
|
||||
export type ContainedControlSize = "xs" | "sm";
|
||||
|
||||
// A row decides how big its controls are, so a button or field placed in it needs no size of
|
||||
// its own. A size passed to the control still wins.
|
||||
const ControlSizeContext = createContext<ContainedControlSize | null>(null);
|
||||
|
||||
export function ControlSizeProvider({
|
||||
size,
|
||||
children,
|
||||
}: {
|
||||
size: ContainedControlSize;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ControlSizeContext.Provider value={size}>{children}</ControlSizeContext.Provider>;
|
||||
}
|
||||
|
||||
/** The size the surrounding container asks for, or null outside one. */
|
||||
export function useContainedControlSize(): ContainedControlSize | null {
|
||||
return useContext(ControlSizeContext);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
|
||||
import { useContainedControlSize } from "@/components/ui/control-size";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
@@ -33,12 +32,10 @@ interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"
|
||||
export function DropdownTrigger({
|
||||
children,
|
||||
leading,
|
||||
size: sizeProp,
|
||||
size = "sm",
|
||||
disabled,
|
||||
...props
|
||||
}: DropdownTriggerProps): ReactElement {
|
||||
const containedSize = useContainedControlSize();
|
||||
const size = sizeProp ?? containedSize ?? "sm";
|
||||
let sizeStyle: ViewStyle;
|
||||
if (size === "xs") {
|
||||
sizeStyle = styles.xs;
|
||||
|
||||
@@ -23,16 +23,6 @@ import {
|
||||
resolveControlInteractionStyles,
|
||||
type FieldControlSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { useContainedControlSize } from "@/components/ui/control-size";
|
||||
|
||||
const FIELD_CHROME = { xs: "chromeXs", sm: "chromeSm", md: "chromeMd" } as const satisfies Record<
|
||||
FieldControlSize,
|
||||
string
|
||||
>;
|
||||
const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record<
|
||||
FieldControlSize,
|
||||
string
|
||||
>;
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
@@ -169,13 +159,11 @@ function assignTextInputRef(
|
||||
}
|
||||
|
||||
export const FormTextInput = forwardRef<EditingTextInputHandle, FormTextInputProps>(
|
||||
function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) {
|
||||
function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const containedSize = useContainedControlSize();
|
||||
const size = sizeProp ?? containedSize ?? "md";
|
||||
const isDisabled = editable === false;
|
||||
const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]];
|
||||
const inputSizeStyle = formInputStyles[FIELD_TEXT[size]];
|
||||
const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd;
|
||||
const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd;
|
||||
const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]);
|
||||
const setInputRef = useCallback(
|
||||
(node: EditingTextInputHandle | null) => {
|
||||
@@ -274,9 +262,6 @@ const formInputStyles = StyleSheet.create((theme) => {
|
||||
chrome: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
chromeXs: {
|
||||
...geometry.fieldControlXs,
|
||||
},
|
||||
chromeSm: {
|
||||
...geometry.fieldControlSm,
|
||||
},
|
||||
@@ -304,9 +289,6 @@ const formInputStyles = StyleSheet.create((theme) => {
|
||||
outlineColor: "transparent",
|
||||
outlineWidth: 0,
|
||||
},
|
||||
inputXs: {
|
||||
...geometry.fieldTextXs,
|
||||
},
|
||||
inputSm: {
|
||||
...geometry.fieldTextSm,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
export const foregroundIconColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
|
||||
export const mutedIconColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
@@ -58,7 +58,6 @@ vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
});
|
||||
|
||||
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
|
||||
import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout";
|
||||
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
|
||||
|
||||
/** Renders whatever the portal is currently holding, from its own place in the tree. */
|
||||
@@ -201,35 +200,3 @@ describe("IsolatedBottomSheetModal context bridging", () => {
|
||||
).toEqual(["false", "true"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bottom sheet layout subscription", () => {
|
||||
it("stops updating a dismissed sheet when the window resizes", () => {
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
let layout: ReturnType<typeof useAnimatedLayout> | undefined;
|
||||
const viewport = document.documentElement;
|
||||
const originalWidth = Object.getOwnPropertyDescriptor(viewport, "clientWidth");
|
||||
function resize(width: number) {
|
||||
Object.defineProperty(viewport, "clientWidth", { configurable: true, value: width });
|
||||
act(() => window.dispatchEvent(new Event("resize")));
|
||||
}
|
||||
function LayoutProbe() {
|
||||
layout = useAnimatedLayout(undefined, 0, 0);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
act(() => root.render(<LayoutProbe />));
|
||||
if (!layout) throw new Error("Sheet layout did not mount");
|
||||
const mountedLayout = layout;
|
||||
resize(640);
|
||||
expect(mountedLayout.get().window.width).toBe(640);
|
||||
act(() => root.render(null));
|
||||
resize(960);
|
||||
expect(mountedLayout.get().window.width).toBe(640);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
if (originalWidth) Object.defineProperty(viewport, "clientWidth", originalWidth);
|
||||
else Reflect.deleteProperty(viewport, "clientWidth");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
segmentedIconSize,
|
||||
type SegmentedControlSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { useContainedControlSize } from "@/components/ui/control-size";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
|
||||
@@ -49,13 +48,11 @@ export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
size: sizeProp,
|
||||
size = "md",
|
||||
hideLabels = false,
|
||||
style,
|
||||
testID,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const containedSize = useContainedControlSize();
|
||||
const size = sizeProp ?? containedSize ?? "md";
|
||||
const sizeStyles = {
|
||||
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
|
||||
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
|
||||
|
||||
@@ -296,7 +296,6 @@ describe("cancelComposerAgent", () => {
|
||||
function baseInput(): {
|
||||
client: ComposerCancelClient & { canceledIds: string[] };
|
||||
agentId: string;
|
||||
isAgentPublished: boolean;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
@@ -310,7 +309,6 @@ describe("cancelComposerAgent", () => {
|
||||
},
|
||||
},
|
||||
agentId: "agent",
|
||||
isAgentPublished: true,
|
||||
isAgentRunning: true,
|
||||
isCancellingAgent: false,
|
||||
isConnected: true,
|
||||
@@ -342,13 +340,6 @@ describe("cancelComposerAgent", () => {
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing while the host has not published the agent", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isAgentPublished: false });
|
||||
expect(result).toBeNull();
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing when the agent is already being canceled", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
|
||||
@@ -428,29 +419,6 @@ describe("pickAndPersistImages", () => {
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("queues a prompt behind earlier ones without sending while the agent is not ready", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const queued: QueuedComposerMessage = { id: "later", text: "later", attachments: [] };
|
||||
const queue = createFakeQueue(new Map([["agent", [queued]]]));
|
||||
|
||||
const outcome = await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "next follow-up",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
isAgentReady: false,
|
||||
queue,
|
||||
});
|
||||
|
||||
expect(outcome).toBe("queued");
|
||||
expect(client.calls).toEqual([]);
|
||||
expect(stream.tail.get("agent") ?? []).toEqual([]);
|
||||
expect(queue.read("agent").map((item) => item.text)).toEqual(["later", "next follow-up"]);
|
||||
});
|
||||
|
||||
it("forwards the configured active-turn intent without provider capability checks", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
@@ -462,8 +430,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [],
|
||||
encodeImages: async () => [],
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
|
||||
@@ -480,8 +446,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [],
|
||||
encodeImages: async () => [],
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
activeTurnBehavior: "steer",
|
||||
activeTurnId: "turn-1",
|
||||
});
|
||||
@@ -494,8 +458,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [],
|
||||
encodeImages: async () => [],
|
||||
submission: legacy,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
expect(legacy.tail.get("legacy")?.[0]?.turnId).toBeUndefined();
|
||||
@@ -514,8 +476,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
}),
|
||||
).rejects.toBe(rejection);
|
||||
|
||||
@@ -536,8 +496,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
}),
|
||||
).rejects.toBe(transportError);
|
||||
|
||||
@@ -561,8 +519,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
}),
|
||||
).rejects.toBe(transportError);
|
||||
});
|
||||
@@ -584,8 +540,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
).rejects.toBe(transportError);
|
||||
@@ -606,8 +560,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
});
|
||||
|
||||
expect(client.calls).toHaveLength(1);
|
||||
@@ -654,8 +606,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachmentSubmitFormat: "legacy-github",
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
});
|
||||
|
||||
expect(client.calls[0].options.attachments).toEqual([
|
||||
@@ -689,8 +639,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
});
|
||||
|
||||
expect(stream.head.get("agent")).toHaveLength(2);
|
||||
@@ -708,8 +656,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options).toMatchObject({
|
||||
@@ -730,8 +676,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [review],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||
@@ -750,8 +694,6 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [browserElement],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
isAgentReady: true,
|
||||
queue: createFakeQueue(),
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([
|
||||
@@ -854,7 +796,6 @@ describe("sendQueuedComposerMessageNow", () => {
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
isAgentReady: true,
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
@@ -873,7 +814,6 @@ describe("sendQueuedComposerMessageNow", () => {
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
isAgentReady: true,
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
@@ -885,27 +825,6 @@ describe("sendQueuedComposerMessageNow", () => {
|
||||
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
|
||||
});
|
||||
|
||||
it("leaves the queue untouched while the agent is not ready", async () => {
|
||||
const queued: QueuedComposerMessage[] = [
|
||||
{ id: "msg-1", text: "first", attachments: [] },
|
||||
{ id: "msg-2", text: "second", attachments: [] },
|
||||
];
|
||||
const queue = createFakeQueue(new Map([["agent", queued]]));
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
isAgentReady: false,
|
||||
messageId: "msg-2",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
submitted.push(input);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "waiting" });
|
||||
expect(submitted).toEqual([]);
|
||||
expect(queue.state.get("agent")).toBe(queued);
|
||||
});
|
||||
|
||||
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
|
||||
const queue = createFakeQueue(
|
||||
new Map([
|
||||
@@ -920,7 +839,6 @@ describe("sendQueuedComposerMessageNow", () => {
|
||||
);
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
isAgentReady: true,
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async () => {
|
||||
|
||||
@@ -170,14 +170,13 @@ export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(in
|
||||
export interface CancelComposerAgentInput {
|
||||
client: ComposerCancelClient | null;
|
||||
agentId: string;
|
||||
isAgentPublished: boolean;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<void> | null {
|
||||
if (!input.isAgentPublished || !input.isAgentRunning || input.isCancellingAgent) return null;
|
||||
if (!input.isAgentRunning || input.isCancellingAgent) return null;
|
||||
if (!input.isConnected || !input.client) return null;
|
||||
try {
|
||||
return Promise.resolve(input.client.cancelAgent(input.agentId));
|
||||
@@ -189,9 +188,6 @@ export function cancelComposerAgent(input: CancelComposerAgentInput): Promise<vo
|
||||
export interface DispatchComposerAgentMessageInput {
|
||||
client: ComposerSendClient;
|
||||
agentId: string;
|
||||
/** See isAgentReadyForPrompts. Prompts for an agent that is not ready wait in its queue. */
|
||||
isAgentReady: boolean;
|
||||
queue: QueueWriter;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
|
||||
@@ -205,12 +201,7 @@ export interface DispatchComposerAgentMessageInput {
|
||||
|
||||
export async function dispatchComposerAgentMessage(
|
||||
input: DispatchComposerAgentMessageInput,
|
||||
): Promise<"sent" | "queued"> {
|
||||
if (!input.isAgentReady) {
|
||||
// A creating agent takes prompts after its initial one; this waits behind it in the queue.
|
||||
queueComposerMessage(input);
|
||||
return "queued";
|
||||
}
|
||||
): Promise<void> {
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
|
||||
format: input.attachmentSubmitFormat,
|
||||
});
|
||||
@@ -235,7 +226,6 @@ export async function dispatchComposerAgentMessage(
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
input.submission.accept(input.agentId, clientMessageId);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
input.submission.reject(input.agentId, clientMessageId);
|
||||
throw error;
|
||||
@@ -303,8 +293,6 @@ export function editQueuedComposerMessage(
|
||||
|
||||
export interface SendQueuedComposerMessageNowInput {
|
||||
agentId: string;
|
||||
/** See DispatchComposerAgentMessageInput. A queued prompt stays in place until then. */
|
||||
isAgentReady: boolean;
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
|
||||
@@ -313,7 +301,6 @@ export interface SendQueuedComposerMessageNowInput {
|
||||
|
||||
export type SendQueuedComposerMessageNowResult =
|
||||
| { status: "missing" }
|
||||
| { status: "waiting" }
|
||||
| { status: "submitted" }
|
||||
| { status: "failed"; errorMessage: string };
|
||||
|
||||
@@ -322,7 +309,6 @@ export async function sendQueuedComposerMessageNow(
|
||||
): Promise<SendQueuedComposerMessageNowResult> {
|
||||
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||
if (!item) return { status: "missing" };
|
||||
if (!input.isAgentReady) return { status: "waiting" };
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useAgentCreation } from "@/workspace-creation";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
@@ -421,56 +420,6 @@ function selectAgentControlsSlice(
|
||||
};
|
||||
}
|
||||
|
||||
/** A creating agent shows its submitted configuration, read-only, until the host publishes it. */
|
||||
function useAgentControlsSource(serverId: string, agentId: string) {
|
||||
const publishedAgent = useSessionStore(
|
||||
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
|
||||
);
|
||||
const creation = useAgentCreation(serverId, agentId);
|
||||
const hasClient = useSessionStore((state) => Boolean(state.sessions[serverId]?.client));
|
||||
const agent = useMemo<AgentControlsSlice>(() => {
|
||||
if (publishedAgent || !creation) return publishedAgent;
|
||||
return {
|
||||
provider: creation.provider,
|
||||
// The destination is not known yet; never stand in the source directory for it.
|
||||
cwd: null,
|
||||
runtimeModelId: null,
|
||||
model: creation.config.model,
|
||||
features: undefined,
|
||||
runtimeThinkingOptionId: null,
|
||||
thinkingOptionId: creation.config.thinkingOptionId,
|
||||
lastUsage: undefined,
|
||||
};
|
||||
}, [creation, publishedAgent]);
|
||||
return {
|
||||
agent,
|
||||
isPublished: publishedAgent !== null,
|
||||
canEdit: hasClient && publishedAgent !== null,
|
||||
submittedModeId: creation?.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function ignoreModeSelection(): void {}
|
||||
|
||||
function resolveModeControl(input: {
|
||||
live: AgentModeControlValue | null;
|
||||
isPublished: boolean;
|
||||
submittedModeId: string | null;
|
||||
entry: ReturnType<typeof resolveSnapshotSelectedEntry>;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
}): AgentModeControlValue | null {
|
||||
if (input.isPublished) return input.live;
|
||||
if (input.entry?.status !== "ready" || !input.entry.modes?.length) return null;
|
||||
return {
|
||||
provider: input.entry.provider,
|
||||
providerDefinitions: input.providerDefinitions,
|
||||
modeOptions: input.entry.modes,
|
||||
selectedModeId: input.submittedModeId,
|
||||
onSelectMode: ignoreModeSelection,
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSnapshotSelectedEntry(
|
||||
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
|
||||
agentProvider: string | undefined,
|
||||
@@ -1607,13 +1556,14 @@ export const AgentControls = memo(function AgentControls({
|
||||
isCompactLayout,
|
||||
}: AgentControlsProps) {
|
||||
const { updatePreferences } = useFormPreferences();
|
||||
const { agent, isPublished, canEdit, submittedModeId } = useAgentControlsSource(
|
||||
serverId,
|
||||
agentId,
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const toast = useToast();
|
||||
const liveModeControl = useLiveAgentModeControl(serverId, agentId);
|
||||
const modeControl = useLiveAgentModeControl(serverId, agentId);
|
||||
const commandCenterModes = toCommandCenterModes(modeControl);
|
||||
const modeProviderDefinitions = getModeProviderDefinitions(modeControl);
|
||||
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
@@ -1636,26 +1586,6 @@ export const AgentControls = memo(function AgentControls({
|
||||
[agent?.provider, snapshotEntries],
|
||||
);
|
||||
|
||||
const modeControl = useMemo(
|
||||
() =>
|
||||
resolveModeControl({
|
||||
live: liveModeControl,
|
||||
isPublished,
|
||||
submittedModeId,
|
||||
entry: snapshotSelectedEntry,
|
||||
providerDefinitions: agentProviderDefinitions,
|
||||
}),
|
||||
[
|
||||
agentProviderDefinitions,
|
||||
isPublished,
|
||||
liveModeControl,
|
||||
snapshotSelectedEntry,
|
||||
submittedModeId,
|
||||
],
|
||||
);
|
||||
const commandCenterModes = toCommandCenterModes(modeControl);
|
||||
const modeProviderDefinitions = getModeProviderDefinitions(modeControl);
|
||||
|
||||
const agentProviderModels = useMemo(
|
||||
() => buildAgentProviderModels(agent?.provider, models),
|
||||
[agent?.provider, models],
|
||||
@@ -1839,7 +1769,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
const commandCenterRegistration = (
|
||||
<AgentControlCommandCenterRegistration
|
||||
sourceId={`agent:${serverId}:${agentId}`}
|
||||
enabled={canEdit}
|
||||
enabled={Boolean(client)}
|
||||
controls={commandCenterControls}
|
||||
/>
|
||||
);
|
||||
@@ -1884,7 +1814,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
onRetryModelProvider={handleRetryModelProvider}
|
||||
isRetryingModelProvider={snapshotIsRefreshing}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!canEdit}
|
||||
disabled={!client}
|
||||
modeControl={modeControl}
|
||||
modelSelectorServerId={serverId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
|
||||
@@ -63,7 +63,6 @@ export interface AgentInputDraft {
|
||||
attachments: UserComposerAttachment[];
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
flush: () => void;
|
||||
isHydrated: boolean;
|
||||
attachmentFocusRequestId: number;
|
||||
composerState: DraftComposerState | null;
|
||||
@@ -173,8 +172,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[draftKey, textPublication],
|
||||
);
|
||||
|
||||
const flushDraft = useCallback(() => textPublication.flush(), [textPublication]);
|
||||
|
||||
const replaceText = useCallback(
|
||||
(nextText: string) => {
|
||||
textPublication.cancel();
|
||||
@@ -354,7 +351,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
return {
|
||||
textSource,
|
||||
editText,
|
||||
flush: flushDraft,
|
||||
replaceText,
|
||||
textReplacement,
|
||||
attachments,
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
} from "@/workspace-tabs/model";
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { transferComposerFollowUps } from "@/composer/submission/writer";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -185,7 +184,8 @@ async function submitDraftCreateRequest(input: {
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||
};
|
||||
const result = await client.createAgent(options);
|
||||
const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId];
|
||||
const result = creation ? await creation.retry(options) : await client.createAgent(options);
|
||||
|
||||
return {
|
||||
agentId: result.id,
|
||||
@@ -466,6 +466,10 @@ export function WorkspaceDraftAgentTab({
|
||||
selectModelMessage: t("workspaceSetup.errors.selectModel"),
|
||||
}),
|
||||
createRequest: async ({ attempt, text, images, attachments, cwd }) => {
|
||||
if (pendingAutoSubmit?.agentCreation) {
|
||||
const result = await pendingAutoSubmit.agentCreation.result;
|
||||
return { agentId: result.id, result };
|
||||
}
|
||||
return submitDraftCreateRequest({
|
||||
draftId,
|
||||
attempt,
|
||||
@@ -487,7 +491,6 @@ export function WorkspaceDraftAgentTab({
|
||||
clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey });
|
||||
useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId });
|
||||
onCreated(result);
|
||||
transferComposerFollowUps(serverId, tabId, result.id);
|
||||
},
|
||||
});
|
||||
const turnPresentation = useMemo(
|
||||
|
||||
@@ -58,7 +58,6 @@ import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import {
|
||||
selectAgentSentMessages,
|
||||
selectAgentTurnPresentation,
|
||||
selectIsAgentPublished,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
@@ -89,9 +88,9 @@ import {
|
||||
toggleForgeAttachmentFromPicker,
|
||||
uploadFileAttachments,
|
||||
type AttachmentPersister,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "@/composer/actions";
|
||||
import { isAgentReadyForPrompts } from "@/workspace-creation";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -124,10 +123,7 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
import {
|
||||
createAgentMessageQueueWriter,
|
||||
createMessageSubmissionWriter,
|
||||
} from "@/composer/submission/writer";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
@@ -262,8 +258,8 @@ function resolveCheckoutRemoteUrl(
|
||||
return checkoutStatus?.remoteUrl ?? null;
|
||||
}
|
||||
|
||||
function buildCancelButtonStyle(canCancel: boolean, isCancellingAgent: boolean): object[] {
|
||||
const disabled = !canCancel || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||
}
|
||||
|
||||
@@ -1007,8 +1003,7 @@ function ComposerAutocompleteBinding({
|
||||
recall,
|
||||
configuration,
|
||||
inputRef,
|
||||
children,
|
||||
anchorStyle,
|
||||
anchorRef,
|
||||
show,
|
||||
ref,
|
||||
}: {
|
||||
@@ -1020,8 +1015,7 @@ function ComposerAutocompleteBinding({
|
||||
"userInput" | "cursorIndex" | "onAutocompleteApplied"
|
||||
>;
|
||||
inputRef: React.RefObject<MessageInputRef | null>;
|
||||
children: React.ReactNode;
|
||||
anchorStyle: React.ComponentProps<typeof View>["style"];
|
||||
anchorRef: React.RefObject<View | null>;
|
||||
show: boolean;
|
||||
ref: React.Ref<ComposerAutocompleteHandle>;
|
||||
}) {
|
||||
@@ -1048,7 +1042,7 @@ function ComposerAutocompleteBinding({
|
||||
return (
|
||||
<ComposerAutocomplete
|
||||
visible={autocomplete.isVisible && isShown}
|
||||
anchorStyle={anchorStyle}
|
||||
anchorRef={anchorRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={onSelect}
|
||||
@@ -1056,9 +1050,7 @@ function ComposerAutocompleteBinding({
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
>
|
||||
{children}
|
||||
</ComposerAutocomplete>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1087,7 +1079,7 @@ interface ComposerCancelButtonProps {
|
||||
buttonIconSize: number;
|
||||
cancelButtonStyle: (object | undefined)[];
|
||||
handleCancelAgent: () => void;
|
||||
canCancel: boolean;
|
||||
isConnected: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
agentInterruptKeys: ReturnType<typeof useShortcutKeys>;
|
||||
t: TFunction;
|
||||
@@ -1097,7 +1089,7 @@ function ComposerCancelButton({
|
||||
buttonIconSize,
|
||||
cancelButtonStyle,
|
||||
handleCancelAgent,
|
||||
canCancel,
|
||||
isConnected,
|
||||
isCancellingAgent,
|
||||
agentInterruptKeys,
|
||||
t,
|
||||
@@ -1115,7 +1107,7 @@ function ComposerCancelButton({
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!canCancel || isCancellingAgent}
|
||||
disabled={!isConnected || isCancellingAgent}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
style={cancelButtonStyle}
|
||||
@@ -1310,6 +1302,8 @@ function ComposerContentImpl({
|
||||
);
|
||||
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
|
||||
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
@@ -1572,12 +1566,9 @@ function ComposerContentImpl({
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: targetAgentId,
|
||||
isAgentReady: isAgentReadyForPrompts(serverId, targetAgentId),
|
||||
queue: createAgentMessageQueueWriter(serverId),
|
||||
text,
|
||||
attachments: sendAttachments,
|
||||
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
|
||||
@@ -1588,7 +1579,10 @@ function ComposerContentImpl({
|
||||
activeTurnBehavior,
|
||||
activeTurnId:
|
||||
activeTurnBehavior === "steer"
|
||||
? (selectAgentTurnPresentation(session, targetAgentId).turnId ?? undefined)
|
||||
? (selectAgentTurnPresentation(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
targetAgentId,
|
||||
).turnId ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
@@ -1622,7 +1616,13 @@ function ComposerContentImpl({
|
||||
);
|
||||
const hasAgent = agentState.status !== null;
|
||||
|
||||
const queueWriter = useMemo(() => createAgentMessageQueueWriter(serverId), [serverId]);
|
||||
const queueWriter = useMemo<QueueWriter>(
|
||||
() => ({
|
||||
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
||||
write: (updater) => setQueuedMessages(serverId, updater),
|
||||
}),
|
||||
[serverId, setQueuedMessages],
|
||||
);
|
||||
|
||||
const queueMessage = useCallback(
|
||||
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
|
||||
@@ -1892,10 +1892,6 @@ function ComposerContentImpl({
|
||||
const cancellation = cancelComposerAgent({
|
||||
client,
|
||||
agentId: targetAgentId,
|
||||
isAgentPublished: selectIsAgentPublished(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
targetAgentId,
|
||||
),
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
@@ -1952,7 +1948,6 @@ function ComposerContentImpl({
|
||||
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId,
|
||||
isAgentReady: isAgentReadyForPrompts(serverId, agentId),
|
||||
messageId: id,
|
||||
queue: queueWriter,
|
||||
submitMessage: ({ text, attachments: queuedAttachments }) =>
|
||||
@@ -1963,7 +1958,7 @@ function ComposerContentImpl({
|
||||
setSendError(result.errorMessage);
|
||||
}
|
||||
},
|
||||
[agentId, queueWriter, serverId, submitMessage, t],
|
||||
[agentId, queueWriter, submitMessage, t],
|
||||
);
|
||||
|
||||
const handleQueue = useCallback(
|
||||
@@ -2011,14 +2006,9 @@ function ComposerContentImpl({
|
||||
[agentId, recall, replaceUserInput, serverId],
|
||||
);
|
||||
|
||||
const isAgentPublished = useSessionStore((state) =>
|
||||
selectIsAgentPublished(state.sessions[serverId], agentId),
|
||||
);
|
||||
// A creating agent cannot be stopped until the host publishes it.
|
||||
const canCancelAgent = isConnected && isAgentPublished;
|
||||
const cancelButtonStyle = useMemo(
|
||||
() => buildCancelButtonStyle(canCancelAgent, isCancellingAgent),
|
||||
[canCancelAgent, isCancellingAgent],
|
||||
() => buildCancelButtonStyle(isConnected, isCancellingAgent),
|
||||
[isConnected, isCancellingAgent],
|
||||
);
|
||||
|
||||
const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
|
||||
@@ -2035,7 +2025,7 @@ function ComposerContentImpl({
|
||||
buttonIconSize={buttonIconSize}
|
||||
cancelButtonStyle={cancelButtonStyle}
|
||||
handleCancelAgent={handleCancelAgent}
|
||||
canCancel={canCancelAgent}
|
||||
isConnected={isConnected}
|
||||
isCancellingAgent={isCancellingAgent}
|
||||
agentInterruptKeys={agentInterruptKeys}
|
||||
t={t}
|
||||
@@ -2045,9 +2035,9 @@ function ComposerContentImpl({
|
||||
agentInterruptKeys,
|
||||
buttonIconSize,
|
||||
cancelButtonStyle,
|
||||
canCancelAgent,
|
||||
handleCancelAgent,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -2389,6 +2379,7 @@ function ComposerContentImpl({
|
||||
pluginClientSlashCommands,
|
||||
],
|
||||
);
|
||||
const messageInputContainerRef = useRef<View>(null);
|
||||
|
||||
const isSubmitLoadingVisible =
|
||||
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
|
||||
@@ -2447,16 +2438,17 @@ function ComposerContentImpl({
|
||||
{isFullscreen ? null : queueList}
|
||||
{!isFullscreen && sendErrorNode}
|
||||
|
||||
<ComposerAutocompleteBinding
|
||||
text={textSource}
|
||||
cursor={cursor}
|
||||
recall={recall}
|
||||
inputRef={messageInputRef}
|
||||
anchorStyle={messageInputContainerStyle}
|
||||
show={mode.showAutocomplete && !isFullscreen}
|
||||
ref={autocompleteRef}
|
||||
configuration={autocompleteConfiguration}
|
||||
>
|
||||
<View ref={messageInputContainerRef} style={messageInputContainerStyle}>
|
||||
<ComposerAutocompleteBinding
|
||||
text={textSource}
|
||||
cursor={cursor}
|
||||
recall={recall}
|
||||
inputRef={messageInputRef}
|
||||
anchorRef={messageInputContainerRef}
|
||||
show={mode.showAutocomplete && !isFullscreen}
|
||||
ref={autocompleteRef}
|
||||
configuration={autocompleteConfiguration}
|
||||
/>
|
||||
<ComposerForgeBinding
|
||||
text={textSource}
|
||||
configuration={forgeConfiguration}
|
||||
@@ -2536,7 +2528,7 @@ function ComposerContentImpl({
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
{pluginAttachments.picker}
|
||||
</ComposerAutocompleteBinding>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useDictation } from "@/hooks/use-dictation";
|
||||
import { hasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import { DictationOverlay } from "@/components/dictation-controls";
|
||||
@@ -58,7 +57,7 @@ import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
@@ -77,7 +76,6 @@ const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({
|
||||
}));
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityHint,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
resolveVoiceAccessibilityLabel,
|
||||
resolveVoiceTooltipText,
|
||||
@@ -85,7 +83,6 @@ import {
|
||||
import {
|
||||
applyDictationTranscript,
|
||||
computeCanStartDictation,
|
||||
resolveAlternateSendAction,
|
||||
resolveComposerSurfacePresentation,
|
||||
resolveComposerSurfaceText,
|
||||
resolveRetainedSelection,
|
||||
@@ -402,6 +399,8 @@ interface DesktopKeyPressContext {
|
||||
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
|
||||
input: ComposerKeyPressEvent["input"];
|
||||
submitOnEnter: boolean;
|
||||
isAgentRunning: boolean;
|
||||
onQueue: ((payload: MessagePayload) => void) | undefined;
|
||||
isSubmitDisabled: boolean;
|
||||
isSubmitLoading: boolean;
|
||||
disabled: boolean;
|
||||
@@ -431,12 +430,15 @@ function handleDesktopKeyPressImpl(
|
||||
if (!ctx.submitOnEnter) return;
|
||||
if (shiftKey) return;
|
||||
|
||||
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
|
||||
event.preventDefault();
|
||||
if (metaKey || ctrlKey) {
|
||||
if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) {
|
||||
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
|
||||
event.preventDefault();
|
||||
ctx.handleAlternateSendAction();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
|
||||
event.preventDefault();
|
||||
ctx.handleDefaultSendAction();
|
||||
}
|
||||
|
||||
@@ -767,10 +769,8 @@ function SendButtonTooltip({
|
||||
canPressLoadingButton,
|
||||
onSubmitLoadingPress,
|
||||
onDefaultSendAction,
|
||||
onLongPressSend,
|
||||
isSendButtonDisabled,
|
||||
submitAccessibilityLabel,
|
||||
submitAccessibilityHint,
|
||||
sendButtonCombinedStyle,
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
@@ -784,10 +784,8 @@ function SendButtonTooltip({
|
||||
canPressLoadingButton: boolean;
|
||||
onSubmitLoadingPress: (() => void) | undefined;
|
||||
onDefaultSendAction: () => void;
|
||||
onLongPressSend: () => void;
|
||||
isSendButtonDisabled: boolean;
|
||||
submitAccessibilityLabel: string;
|
||||
submitAccessibilityHint: string | undefined;
|
||||
sendButtonCombinedStyle: React.ComponentProps<typeof TooltipTrigger>["style"];
|
||||
isSubmitLoading: boolean;
|
||||
submitIcon: "arrow" | "return";
|
||||
@@ -802,10 +800,8 @@ function SendButtonTooltip({
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={canPressLoadingButton ? onSubmitLoadingPress : onDefaultSendAction}
|
||||
onLongPress={canPressLoadingButton ? undefined : onLongPressSend}
|
||||
disabled={isSendButtonDisabled}
|
||||
accessibilityLabel={submitAccessibilityLabel}
|
||||
accessibilityHint={submitAccessibilityHint}
|
||||
accessibilityRole="button"
|
||||
testID={submitButtonTestID}
|
||||
style={sendButtonCombinedStyle}
|
||||
@@ -1709,19 +1705,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
});
|
||||
}, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]);
|
||||
|
||||
const alternateSendAction = resolveAlternateSendAction({
|
||||
defaultSendBehavior,
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
});
|
||||
|
||||
const handleLongPressSend = useCallback(() => {
|
||||
if (alternateSendAction && isNative) {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
||||
}
|
||||
handleAlternateSendAction();
|
||||
}, [alternateSendAction, handleAlternateSendAction]);
|
||||
|
||||
usePasteImagesEffect({
|
||||
textarea: webTextArea,
|
||||
isConnected,
|
||||
@@ -1755,6 +1738,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
selectionRef.current,
|
||||
),
|
||||
submitOnEnter: shouldSubmitOnEnter,
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
isSubmitDisabled,
|
||||
isSubmitLoading,
|
||||
disabled,
|
||||
@@ -1766,6 +1751,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
shouldHandleWebKeyPress,
|
||||
onKeyPressCallback,
|
||||
shouldSubmitOnEnter,
|
||||
isAgentRunning,
|
||||
onQueue,
|
||||
isSubmitDisabled,
|
||||
isSubmitLoading,
|
||||
disabled,
|
||||
@@ -1805,11 +1792,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isAgentRunning,
|
||||
t,
|
||||
});
|
||||
const submitAccessibilityHint = resolveSubmitAccessibilityHint({
|
||||
canPressLoadingButton,
|
||||
alternateSendAction,
|
||||
t,
|
||||
});
|
||||
|
||||
const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
@@ -1970,10 +1952,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton={canPressLoadingButton}
|
||||
onSubmitLoadingPress={onSubmitLoadingPress}
|
||||
onDefaultSendAction={handleDefaultSendAction}
|
||||
onLongPressSend={handleLongPressSend}
|
||||
isSendButtonDisabled={isPresentedSendDisabled}
|
||||
submitAccessibilityLabel={submitAccessibilityLabel}
|
||||
submitAccessibilityHint={submitAccessibilityHint}
|
||||
sendButtonCombinedStyle={sendButtonCombinedStyle}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
submitIcon={submitIcon}
|
||||
@@ -1991,10 +1971,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton,
|
||||
onSubmitLoadingPress,
|
||||
handleDefaultSendAction,
|
||||
handleLongPressSend,
|
||||
isPresentedSendDisabled,
|
||||
submitAccessibilityLabel,
|
||||
submitAccessibilityHint,
|
||||
sendButtonCombinedStyle,
|
||||
isSubmitLoading,
|
||||
submitIcon,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveSendTooltipLabel,
|
||||
resolveSubmitAccessibilityHint,
|
||||
resolveSubmitAccessibilityLabel,
|
||||
resolveVoiceAccessibilityLabel,
|
||||
resolveVoiceTooltipText,
|
||||
@@ -13,8 +12,6 @@ const translations: Record<string, string> = {
|
||||
"composer.input.sendAndInterrupt": "Send and interrupt",
|
||||
"composer.input.sendAndSteer": "Send and steer",
|
||||
"composer.input.sendMessage": "Send message",
|
||||
"composer.input.longPressToQueue": "Long press to queue",
|
||||
"composer.input.longPressToSendNow": "Long press to send now",
|
||||
"composer.input.queue": "Queue",
|
||||
"composer.input.send": "Send",
|
||||
"composer.voice.unmuteVoiceMode": "Unmute Voice mode",
|
||||
@@ -29,37 +26,6 @@ const translations: Record<string, string> = {
|
||||
const t = ((key: string) => translations[key] ?? key) as never;
|
||||
|
||||
describe("composer input labels", () => {
|
||||
it("names the long-press action in the submit hint only when one exists", () => {
|
||||
expect(
|
||||
resolveSubmitAccessibilityHint({
|
||||
canPressLoadingButton: false,
|
||||
alternateSendAction: "queue",
|
||||
t,
|
||||
}),
|
||||
).toBe("Long press to queue");
|
||||
expect(
|
||||
resolveSubmitAccessibilityHint({
|
||||
canPressLoadingButton: false,
|
||||
alternateSendAction: "send",
|
||||
t,
|
||||
}),
|
||||
).toBe("Long press to send now");
|
||||
expect(
|
||||
resolveSubmitAccessibilityHint({
|
||||
canPressLoadingButton: false,
|
||||
alternateSendAction: null,
|
||||
t,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveSubmitAccessibilityHint({
|
||||
canPressLoadingButton: true,
|
||||
alternateSendAction: "queue",
|
||||
t,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves submit accessibility labels from translations", () => {
|
||||
expect(
|
||||
resolveSubmitAccessibilityLabel({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { SendBehavior } from "@/hooks/use-settings/storage";
|
||||
import type { AlternateSendAction } from "./state";
|
||||
|
||||
export function resolveSubmitAccessibilityLabel(input: {
|
||||
submitButtonAccessibilityLabel: string | undefined;
|
||||
@@ -23,19 +22,6 @@ export function resolveSubmitAccessibilityLabel(input: {
|
||||
return input.t("composer.input.sendMessage");
|
||||
}
|
||||
|
||||
export function resolveSubmitAccessibilityHint(input: {
|
||||
canPressLoadingButton: boolean;
|
||||
alternateSendAction: AlternateSendAction | null;
|
||||
t: TFunction;
|
||||
}): string | undefined {
|
||||
if (input.canPressLoadingButton || !input.alternateSendAction) return undefined;
|
||||
return input.t(
|
||||
input.alternateSendAction === "queue"
|
||||
? "composer.input.longPressToQueue"
|
||||
: "composer.input.longPressToSendNow",
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveVoiceAccessibilityLabel(input: {
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isMuted: boolean;
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
resolveComposerSurfaceText,
|
||||
resolveGlyphRailOffset,
|
||||
resolveRetainedSelection,
|
||||
resolveAlternateSendAction,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
@@ -277,47 +276,6 @@ describe("composer send behavior", () => {
|
||||
expect(defaultAction.calls).toEqual(["queue"]);
|
||||
expect(alternateAction.calls).toEqual(["send"]);
|
||||
});
|
||||
|
||||
it("sends with the alternate action while no turn is running", () => {
|
||||
for (const defaultSendBehavior of ["steer", "interrupt", "queue"] as const) {
|
||||
const alternateAction = actions();
|
||||
runAlternateSendAction({
|
||||
defaultSendBehavior,
|
||||
isAgentRunning: false,
|
||||
onQueue: alternateAction.onQueue,
|
||||
handleSendMessage: alternateAction.handleSendMessage,
|
||||
handleQueueMessage: alternateAction.handleQueueMessage,
|
||||
});
|
||||
expect(alternateAction.calls).toEqual(["send"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers an alternate action only while a turn runs and the composer can queue", () => {
|
||||
const onQueue = () => undefined;
|
||||
expect(
|
||||
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: true, onQueue }),
|
||||
).toBe("queue");
|
||||
expect(
|
||||
resolveAlternateSendAction({
|
||||
defaultSendBehavior: "interrupt",
|
||||
isAgentRunning: true,
|
||||
onQueue,
|
||||
}),
|
||||
).toBe("queue");
|
||||
expect(
|
||||
resolveAlternateSendAction({ defaultSendBehavior: "queue", isAgentRunning: true, onQueue }),
|
||||
).toBe("send");
|
||||
expect(
|
||||
resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: false, onQueue }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveAlternateSendAction({
|
||||
defaultSendBehavior: "queue",
|
||||
isAgentRunning: true,
|
||||
onQueue: undefined,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopRealtimeVoice", () => {
|
||||
|
||||
@@ -125,30 +125,14 @@ export function runDefaultSendAction(ctx: SendActionContext): void {
|
||||
ctx.handleSendMessage();
|
||||
}
|
||||
|
||||
export type AlternateSendAction = "queue" | "send";
|
||||
|
||||
/**
|
||||
* What Mod+Enter and a long-press on Send do instead of the default send. Null while
|
||||
* no turn is running, where both fall back to the default send.
|
||||
*/
|
||||
export function resolveAlternateSendAction(
|
||||
ctx: Pick<SendActionContext, "defaultSendBehavior" | "isAgentRunning" | "onQueue">,
|
||||
): AlternateSendAction | null {
|
||||
if (!ctx.isAgentRunning || !ctx.onQueue) return null;
|
||||
return ctx.defaultSendBehavior === "queue" ? "send" : "queue";
|
||||
}
|
||||
|
||||
export function runAlternateSendAction(ctx: SendActionContext): void {
|
||||
const action = resolveAlternateSendAction(ctx);
|
||||
if (action === "queue") {
|
||||
ctx.handleQueueMessage();
|
||||
return;
|
||||
}
|
||||
if (action === "send") {
|
||||
if (ctx.defaultSendBehavior === "queue") {
|
||||
ctx.handleSendMessage();
|
||||
return;
|
||||
}
|
||||
runDefaultSendAction(ctx);
|
||||
if (ctx.isAgentRunning && ctx.onQueue) {
|
||||
ctx.handleQueueMessage();
|
||||
}
|
||||
}
|
||||
|
||||
export function runMessageInputKeyboardAction(
|
||||
|
||||
@@ -2,9 +2,7 @@ import { afterEach, expect, test } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { UserMessageItem } from "@/types/stream";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { createMessageSubmissionWriter, transferComposerFollowUps } from "./writer";
|
||||
import { createMessageSubmissionWriter } from "./writer";
|
||||
|
||||
const CANONICAL_ONLY_SERVER_ID = "canonical-only";
|
||||
|
||||
@@ -49,59 +47,3 @@ test("tracks submissions when canonical prompts are supported", () => {
|
||||
|
||||
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
|
||||
});
|
||||
|
||||
const TRANSFER_SERVER_ID = "transfer";
|
||||
|
||||
function queued(id: string) {
|
||||
return { id, text: `follow-up ${id}`, attachments: [] };
|
||||
}
|
||||
function queueIds(agentId: string): string[] {
|
||||
return (
|
||||
useSessionStore.getState().sessions[TRANSFER_SERVER_ID]?.queuedMessages.get(agentId) ?? []
|
||||
).map((item) => item.id);
|
||||
}
|
||||
function draftText(agentId: string): string | undefined {
|
||||
return useDraftStore
|
||||
.getState()
|
||||
.getDraftInput(buildDraftStoreKey({ serverId: TRANSFER_SERVER_ID, agentId }))?.text;
|
||||
}
|
||||
function typeDraft(agentId: string, text: string): void {
|
||||
useDraftStore.getState().saveDraftInput({
|
||||
draftKey: buildDraftStoreKey({ serverId: TRANSFER_SERVER_ID, agentId }),
|
||||
draft: { text, attachments: [] },
|
||||
});
|
||||
}
|
||||
|
||||
test("follow-ups move with their IDs and order behind the destination's, and only once", () => {
|
||||
initializeSession(TRANSFER_SERVER_ID, {});
|
||||
useSessionStore.getState().setQueuedMessages(
|
||||
TRANSFER_SERVER_ID,
|
||||
new Map([
|
||||
["optimistic", [queued("a"), queued("b")]],
|
||||
["recovery", [queued("earlier")]],
|
||||
]),
|
||||
);
|
||||
typeDraft("optimistic", "typed but unsent");
|
||||
|
||||
transferComposerFollowUps(TRANSFER_SERVER_ID, "optimistic", "recovery");
|
||||
transferComposerFollowUps(TRANSFER_SERVER_ID, "optimistic", "recovery");
|
||||
transferComposerFollowUps(TRANSFER_SERVER_ID, "recovery", "recovery");
|
||||
|
||||
expect(queueIds("recovery")).toEqual(["earlier", "a", "b"]);
|
||||
expect(queueIds("optimistic")).toEqual([]);
|
||||
expect(draftText("recovery")).toBe("typed but unsent");
|
||||
expect(draftText("optimistic")).toBeUndefined();
|
||||
useSessionStore.getState().clearSession(TRANSFER_SERVER_ID);
|
||||
});
|
||||
|
||||
test("unsent text never replaces a destination draft and stays at its source", () => {
|
||||
initializeSession(TRANSFER_SERVER_ID, {});
|
||||
typeDraft("source", "typed on the source");
|
||||
typeDraft("destination", "already typed here");
|
||||
|
||||
transferComposerFollowUps(TRANSFER_SERVER_ID, "source", "destination");
|
||||
|
||||
expect(draftText("destination")).toBe("already typed here");
|
||||
expect(draftText("source")).toBe("typed on the source");
|
||||
useSessionStore.getState().clearSession(TRANSFER_SERVER_ID);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { MessageSubmissionWriter, QueueWriter } from "@/composer/actions";
|
||||
import type { MessageSubmissionWriter } from "@/composer/actions";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDraftStore, type DraftInput } from "@/stores/draft-store";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
removeSubmittedUserMessage,
|
||||
@@ -65,14 +63,6 @@ export function createMessageSubmissionWriter(serverId: string): MessageSubmissi
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentMessageQueueWriter(serverId: string): QueueWriter {
|
||||
return {
|
||||
read: (agentId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(agentId) ?? [],
|
||||
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
|
||||
};
|
||||
}
|
||||
|
||||
export function handoffCreatedAgentMessageSubmission(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
@@ -80,41 +70,3 @@ export function handoffCreatedAgentMessageSubmission(
|
||||
): boolean {
|
||||
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
|
||||
}
|
||||
|
||||
function isEmptyDraft(draft: DraftInput | undefined): boolean {
|
||||
return !draft || (!draft.text.trim() && draft.attachments.length === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the queued follow-ups and the unsent agent draft of one composer identity to the identity
|
||||
* that now owns them, such as a recovery draft or the agent it creates. Queued messages keep their
|
||||
* IDs and order, after any already queued there; an idle destination agent drains them. The unsent
|
||||
* draft moves only into an empty slot, and otherwise stays where it is. Repeating is harmless.
|
||||
*/
|
||||
export function transferComposerFollowUps(serverId: string, from: string, to: string): void {
|
||||
if (from === to) return;
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (session?.queuedMessages.get(from)?.length) {
|
||||
useSessionStore.getState().setQueuedMessages(serverId, (queues) => {
|
||||
const moving = queues.get(from);
|
||||
if (!moving?.length) return queues;
|
||||
const next = new Map(queues);
|
||||
next.set(to, [...(queues.get(to) ?? []), ...moving]);
|
||||
next.delete(from);
|
||||
return next;
|
||||
});
|
||||
const agent = session.agents.get(to) ?? session.agentDetails.get(to);
|
||||
if (agent?.turn.phase === "idle") {
|
||||
void import("@/runtime/host-runtime").then(({ getHostRuntimeStore }) =>
|
||||
getHostRuntimeStore().drainQueuedAgentMessage(serverId, to),
|
||||
);
|
||||
}
|
||||
}
|
||||
const drafts = useDraftStore.getState();
|
||||
const source = buildDraftStoreKey({ serverId, agentId: from });
|
||||
const destination = buildDraftStoreKey({ serverId, agentId: to });
|
||||
const draft = drafts.getDraftInput(source);
|
||||
if (!draft || isEmptyDraft(draft) || !isEmptyDraft(drafts.getDraftInput(destination))) return;
|
||||
drafts.saveDraftInput({ draftKey: destination, draft });
|
||||
drafts.clearDraftInput({ draftKey: source });
|
||||
}
|
||||
|
||||
@@ -162,34 +162,12 @@ describe("composer track panel", () => {
|
||||
);
|
||||
|
||||
openPanel();
|
||||
expect(document.querySelector("button button")).toBeNull();
|
||||
expect(row("row")?.getAttribute("role")).toBe("menuitem");
|
||||
click(row("row-archive") as Element);
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(onPress).not.toHaveBeenCalled();
|
||||
expect(isPanelOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["Enter", " "])("selects a track row with %j", (key) => {
|
||||
const select = vi.fn();
|
||||
mount(
|
||||
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
|
||||
<ComposerTrackRow accessibilityLabel="Subagent one" testID="row" onPress={select}>
|
||||
<Text>Subagent one</Text>
|
||||
</ComposerTrackRow>
|
||||
</ComposerTrackPill>,
|
||||
);
|
||||
openPanel();
|
||||
const target = row("row") as HTMLElement;
|
||||
act(() => {
|
||||
target.focus();
|
||||
target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
|
||||
target.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true }));
|
||||
});
|
||||
expect(select).toHaveBeenCalledTimes(1);
|
||||
expect(isPanelOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer track pill status mark", () => {
|
||||
|
||||
@@ -233,11 +233,6 @@ export function ComposerTrackRow({
|
||||
[closeOnSelect, onPress, selectItem],
|
||||
);
|
||||
|
||||
const itemDataSet = useMemo(
|
||||
() => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }),
|
||||
[disabled],
|
||||
);
|
||||
|
||||
const renderRow = useCallback(
|
||||
(active: boolean) => (
|
||||
<View style={active ? styles.rowActive : styles.row}>
|
||||
@@ -258,8 +253,7 @@ export function ComposerTrackRow({
|
||||
return (
|
||||
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
||||
<Pressable
|
||||
accessibilityRole="menuitem"
|
||||
dataSet={itemDataSet}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface DesktopEditorOpenTargetInput {
|
||||
}
|
||||
|
||||
export interface DesktopEditorBridge {
|
||||
listTargets?: (input?: { workspacePath?: string }) => Promise<DesktopEditorTargetDescriptor[]>;
|
||||
listTargets?: () => Promise<DesktopEditorTargetDescriptor[]>;
|
||||
openTarget?: (input: DesktopEditorOpenTargetInput) => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isNative } from "@/constants/platform";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -76,9 +75,8 @@ function ChecksRingSvg({
|
||||
height={size}
|
||||
viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`}
|
||||
style={ringStyles.svg}
|
||||
{...(isNative
|
||||
? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" }
|
||||
: { "aria-hidden": true })}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CreationAttempt } from "@/workspace-creation/model";
|
||||
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
|
||||
import equal from "fast-deep-equal";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
@@ -147,7 +146,6 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
workspace: WorkspaceDescriptor;
|
||||
projectViewKey?: string;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceCreations?: readonly CreationAttempt[];
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): SidebarWorkspaceEntry {
|
||||
const projectViewKey = input.projectViewKey ?? input.workspace.projectId;
|
||||
@@ -205,7 +203,6 @@ export function deriveProjectStatusBucket(input: {
|
||||
workspaces: readonly SidebarWorkspacePlacement[];
|
||||
sessions: Record<string, ProjectStatusSession | undefined>;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceCreations?: readonly CreationAttempt[];
|
||||
}): SidebarStateBucket {
|
||||
const workspaceIdsByServer = new Map<string, string[]>();
|
||||
for (const placement of input.workspaces) {
|
||||
@@ -233,7 +230,6 @@ export function deriveProjectStatusBucket(input: {
|
||||
serverId,
|
||||
workspace,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
workspaceCreations: input.workspaceCreations,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
}).status,
|
||||
);
|
||||
@@ -352,7 +348,6 @@ export function buildSidebarWorkspaceEntries(input: {
|
||||
placements: readonly SidebarWorkspacePlacement[];
|
||||
sessions: SidebarWorkspaceSession[];
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceCreations?: readonly CreationAttempt[];
|
||||
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
}): ReadonlyMap<string, SidebarWorkspaceEntry> {
|
||||
if (input.placements.length === 0 || input.sessions.length === 0) {
|
||||
@@ -379,7 +374,6 @@ export function buildSidebarWorkspaceEntries(input: {
|
||||
workspace,
|
||||
projectViewKey: placement.projectViewKey,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
workspaceCreations: input.workspaceCreations,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
});
|
||||
const previousEntry = input.previousEntries?.get(placement.workspaceKey);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useDirectoryDemand } from "./use-directory-demand";
|
||||
|
||||
function Probe({ serverId }: { serverId: string | null }) {
|
||||
useDirectoryDemand(serverId);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useDirectoryDemand", () => {
|
||||
let root: Root | null = null;
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("demands the host directory while mounted and releases it on unmount", () => {
|
||||
const release = vi.fn();
|
||||
const acquire = vi
|
||||
.spyOn(getHostRuntimeStore(), "acquireDirectoryDemand")
|
||||
.mockImplementation(() => release);
|
||||
const container = document.createElement("div");
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => root?.render(<Probe serverId="srv_a" />));
|
||||
expect(acquire).toHaveBeenCalledTimes(1);
|
||||
expect(acquire).toHaveBeenCalledWith("srv_a");
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
|
||||
act(() => root?.render(<Probe serverId={null} />));
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
expect(acquire).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
/**
|
||||
* Keeps a host's directory demanded while the caller is mounted.
|
||||
*
|
||||
* The sidebar only demands the directory while its panel is active, so a compact app that
|
||||
* restores straight into a workspace or agent route has nothing else asking the host for
|
||||
* workspaces and agents. Without demand the route waits on hydration that never starts.
|
||||
*/
|
||||
export function useDirectoryDemand(serverId: string | null | undefined): void {
|
||||
useEffect(() => {
|
||||
if (!serverId) return;
|
||||
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
|
||||
}, [serverId]);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { createPreferredEditorPreference, resolvePreferredEditorId } from "./use-preferred-editor";
|
||||
import { resolvePreferredEditorId } from "./use-preferred-editor";
|
||||
|
||||
describe("resolvePreferredEditorId", () => {
|
||||
it("keeps the stored editor when it is still available", () => {
|
||||
@@ -32,78 +30,3 @@ describe("resolvePreferredEditorId", () => {
|
||||
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project editor preferences", () => {
|
||||
it("changing one project leaves another project's cached and persisted choice unchanged", async () => {
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem("@paseo:preferred-editor", "vscode");
|
||||
const queryClient = new QueryClient();
|
||||
const projectA = createPreferredEditorPreference({
|
||||
scope: { serverId: "host", projectId: "project-a" },
|
||||
queryClient,
|
||||
storage: AsyncStorage,
|
||||
});
|
||||
const projectB = createPreferredEditorPreference({
|
||||
scope: { serverId: "host", projectId: "project-b" },
|
||||
queryClient,
|
||||
storage: AsyncStorage,
|
||||
});
|
||||
expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("vscode");
|
||||
expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode");
|
||||
await projectA.update("cursor");
|
||||
expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor");
|
||||
expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode");
|
||||
queryClient.clear();
|
||||
expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor");
|
||||
expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode");
|
||||
expect(await AsyncStorage.getItem("@paseo:preferred-editor")).toBe("vscode");
|
||||
queryClient.clear();
|
||||
});
|
||||
it("shares a choice across workspaces of the same project while isolating hosts", async () => {
|
||||
await AsyncStorage.clear();
|
||||
const queryClient = new QueryClient();
|
||||
const root = createPreferredEditorPreference({
|
||||
scope: { serverId: "host-a", projectId: "project" },
|
||||
queryClient,
|
||||
storage: AsyncStorage,
|
||||
});
|
||||
const worktree = createPreferredEditorPreference({
|
||||
scope: { serverId: "host-a", projectId: "project" },
|
||||
queryClient,
|
||||
storage: AsyncStorage,
|
||||
});
|
||||
const otherHost = createPreferredEditorPreference({
|
||||
scope: { serverId: "host-b", projectId: "project" },
|
||||
queryClient,
|
||||
storage: AsyncStorage,
|
||||
});
|
||||
await root.update("zed");
|
||||
expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed");
|
||||
expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull();
|
||||
queryClient.clear();
|
||||
expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed");
|
||||
expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("preserves the previous choice when saving fails", async () => {
|
||||
await AsyncStorage.clear();
|
||||
await AsyncStorage.setItem("@paseo:preferred-editor", "vscode");
|
||||
const queryClient = new QueryClient();
|
||||
const preference = createPreferredEditorPreference({
|
||||
scope: { serverId: "host", projectId: "project" },
|
||||
queryClient,
|
||||
storage: {
|
||||
getItem: AsyncStorage.getItem,
|
||||
removeItem: AsyncStorage.removeItem,
|
||||
async setItem() {
|
||||
throw new Error("Storage is full");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode");
|
||||
await expect(preference.update("cursor")).rejects.toThrow("Storage is full");
|
||||
expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode");
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,57 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { readValidatedString, type ValidatedStorage } from "@/storage/validated-storage";
|
||||
import { readValidatedString } from "@/storage/validated-storage";
|
||||
|
||||
type EditorTargetId = string;
|
||||
|
||||
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
|
||||
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
|
||||
|
||||
interface EditorPreferenceStorage extends ValidatedStorage {
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface ProjectEditorScope {
|
||||
serverId: string;
|
||||
projectId: string | null;
|
||||
}
|
||||
|
||||
export function createPreferredEditorPreference({
|
||||
scope,
|
||||
queryClient,
|
||||
storage,
|
||||
}: {
|
||||
scope: ProjectEditorScope;
|
||||
queryClient: QueryClient;
|
||||
storage: EditorPreferenceStorage;
|
||||
}) {
|
||||
const queryKey = ["preferred-editor", scope.serverId, scope.projectId];
|
||||
const storageKey = `${PREFERRED_EDITOR_STORAGE_KEY}:${JSON.stringify([scope.serverId, scope.projectId])}`;
|
||||
async function load() {
|
||||
if (scope.projectId === null) return null;
|
||||
const projectEditor = await readValidatedString(storage, storageKey, z.string().trim().min(1));
|
||||
if (projectEditor !== null) return projectEditor;
|
||||
return readValidatedString(storage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1));
|
||||
}
|
||||
return {
|
||||
queryOptions: {
|
||||
queryKey,
|
||||
queryFn: load,
|
||||
enabled: scope.projectId !== null,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
},
|
||||
async update(editorId: EditorTargetId | null) {
|
||||
if (scope.projectId === null) return;
|
||||
if (editorId) {
|
||||
await storage.setItem(storageKey, editorId);
|
||||
} else {
|
||||
await storage.removeItem(storageKey);
|
||||
}
|
||||
queryClient.setQueryData(queryKey, await load());
|
||||
},
|
||||
};
|
||||
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
|
||||
return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1));
|
||||
}
|
||||
|
||||
export function resolvePreferredEditorId(
|
||||
@@ -70,24 +29,30 @@ export function resolvePreferredEditorId(
|
||||
return availableEditorIds[0] ?? null;
|
||||
}
|
||||
|
||||
export function usePreferredEditor(scope: ProjectEditorScope) {
|
||||
export function usePreferredEditor() {
|
||||
const queryClient = useQueryClient();
|
||||
const { serverId, projectId } = scope;
|
||||
const preference = useMemo(
|
||||
() =>
|
||||
createPreferredEditorPreference({
|
||||
scope: { serverId, projectId },
|
||||
queryClient,
|
||||
storage: AsyncStorage,
|
||||
}),
|
||||
[queryClient, serverId, projectId],
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: PREFERRED_EDITOR_QUERY_KEY,
|
||||
queryFn: loadPreferredEditor,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
const updatePreferredEditor = useCallback(
|
||||
async (editorId: EditorTargetId | null) => {
|
||||
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
|
||||
if (editorId) {
|
||||
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
|
||||
return;
|
||||
}
|
||||
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
const { data, isPending } = useQuery(preference.queryOptions);
|
||||
|
||||
const isLoading = projectId !== null && isPending;
|
||||
return {
|
||||
preferredEditorId: isLoading ? undefined : (data ?? null),
|
||||
isLoading,
|
||||
updatePreferredEditor: preference.update,
|
||||
preferredEditorId: isPending ? undefined : (data ?? null),
|
||||
isLoading: isPending,
|
||||
updatePreferredEditor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWorkspaceCreations } from "@/workspace-creation";
|
||||
import { creatingSidebarEntry } from "@/workspace-creation/presentation";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
@@ -22,7 +19,6 @@ export function useSidebarWorkspaceEntries(
|
||||
placements: readonly SidebarWorkspacePlacement[],
|
||||
enabled = true,
|
||||
): ReadonlyMap<string, SidebarWorkspaceEntry> {
|
||||
const { t } = useTranslation();
|
||||
const serverIds = useMemo(
|
||||
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
|
||||
[placements],
|
||||
@@ -36,7 +32,6 @@ export function useSidebarWorkspaceEntries(
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) =>
|
||||
enabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
|
||||
);
|
||||
const creations = useWorkspaceCreations();
|
||||
const previousEntriesRef = useRef<ReadonlyMap<string, SidebarWorkspaceEntry>>(EMPTY_ENTRIES);
|
||||
|
||||
// Collection ownership is intentional: retained sidebars have one cheap
|
||||
@@ -50,26 +45,13 @@ export function useSidebarWorkspaceEntries(
|
||||
previousEntriesRef.current = EMPTY_ENTRIES;
|
||||
return EMPTY_ENTRIES;
|
||||
}
|
||||
const entries = new Map(
|
||||
buildSidebarWorkspaceEntries({
|
||||
placements,
|
||||
sessions,
|
||||
pendingCreateAttempts,
|
||||
workspaceCreations: creations,
|
||||
previousEntries: previousEntriesRef.current,
|
||||
}),
|
||||
);
|
||||
for (const placement of placements) {
|
||||
if (entries.has(placement.workspaceKey)) continue;
|
||||
const creating = creations.some(
|
||||
(creation) =>
|
||||
creation.serverId === placement.serverId &&
|
||||
creation.workspaceId === placement.workspaceId &&
|
||||
!creation.workspaceObserved,
|
||||
);
|
||||
if (creating) entries.set(placement.workspaceKey, creatingSidebarEntry(placement, t));
|
||||
}
|
||||
const entries = buildSidebarWorkspaceEntries({
|
||||
placements,
|
||||
sessions,
|
||||
pendingCreateAttempts,
|
||||
previousEntries: previousEntriesRef.current,
|
||||
});
|
||||
previousEntriesRef.current = entries;
|
||||
return entries;
|
||||
}, [enabled, pendingCreateAttempts, placements, sessions, creations, t]);
|
||||
}, [enabled, pendingCreateAttempts, placements, sessions]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWorkspaceCreations } from "@/workspace-creation";
|
||||
import { addCreatingSidebarWorkspaces } from "@/workspace-creation/presentation";
|
||||
import { useHostFeatureMap } from "@/runtime/host-features";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
@@ -62,7 +59,6 @@ export function useSidebarProjectStatusBucket(input: {
|
||||
enabled: boolean;
|
||||
}): SidebarStateBucket | null {
|
||||
const { workspaces, enabled } = input;
|
||||
const workspaceCreations = useWorkspaceCreations();
|
||||
const pendingCreateAttempts = useStoreWithEqualityFn(
|
||||
useCreateFlowStore,
|
||||
(state) => state.pendingByDraftId,
|
||||
@@ -76,10 +72,9 @@ export function useSidebarProjectStatusBucket(input: {
|
||||
workspaces,
|
||||
sessions: state.sessions,
|
||||
pendingCreateAttempts,
|
||||
workspaceCreations,
|
||||
});
|
||||
},
|
||||
[enabled, pendingCreateAttempts, workspaces, workspaceCreations],
|
||||
[enabled, pendingCreateAttempts, workspaces],
|
||||
);
|
||||
|
||||
return useStoreWithEqualityFn(useSessionStore, selector, Object.is);
|
||||
@@ -155,12 +150,10 @@ export function useSidebarWorkspacesList(options?: {
|
||||
: undefined;
|
||||
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const creations = useWorkspaceCreations();
|
||||
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
|
||||
const sidebarModel = useMemo(
|
||||
() => addCreatingSidebarWorkspaces(selectPlacements(hostProjects), creations, t),
|
||||
[hostProjects, selectPlacements, creations, t],
|
||||
() => selectPlacements(hostProjects),
|
||||
[hostProjects, selectPlacements],
|
||||
);
|
||||
|
||||
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
|
||||
|
||||
@@ -133,8 +133,6 @@ export const ar: TranslationResources = {
|
||||
sendAndInterrupt: "إرسال ومقاطعة",
|
||||
sendAndSteer: "إرسال وتوجيه",
|
||||
sendMessage: "أرسل رسالة",
|
||||
longPressToQueue: "اضغط مطولًا لإضافتها إلى الطابور",
|
||||
longPressToSendNow: "اضغط مطولًا للإرسال الآن",
|
||||
queue: "طابور",
|
||||
send: "يرسل",
|
||||
},
|
||||
@@ -1339,15 +1337,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "جارٍ إنشاء مساحة العمل…",
|
||||
creatingLabel: "جارٍ إنشاء مساحة العمل",
|
||||
updateHost: "حدّث هذا المضيف لإنشاء مساحة عمل تتضمن وكيلاً.",
|
||||
chooseProjectHost: "اختر مشروعًا ومضيفًا",
|
||||
projectUnavailable: "المشروع غير متاح على المضيف المحدد",
|
||||
workspacePending: "لا يزال إنشاء مساحة العمل جاريًا.",
|
||||
agentPending: "لا يزال إنشاء الوكيل جاريًا.",
|
||||
},
|
||||
title: "مساحة عمل جديدة",
|
||||
create: "يخلق",
|
||||
isolation: {
|
||||
@@ -2269,7 +2258,6 @@ export const ar: TranslationResources = {
|
||||
connections: "اتصالات",
|
||||
agents: "Agents",
|
||||
metadata: "البيانات الوصفية",
|
||||
voice: "الصوت",
|
||||
workspaces: "Workspaces",
|
||||
providers: "مقدمي الخدمات",
|
||||
usage: "الاستخدام",
|
||||
@@ -2292,69 +2280,6 @@ export const ar: TranslationResources = {
|
||||
docs: "الوثائق",
|
||||
saveError: "تعذر تحديث إنشاء البيانات الوصفية",
|
||||
},
|
||||
voice: {
|
||||
offline: "اتصل بهذا المضيف لضبط الصوت.",
|
||||
updateHost: "حدّث المضيف لضبط الصوت.",
|
||||
saveError: "تعذّر تحديث إعدادات الصوت",
|
||||
provider: "المزوّد",
|
||||
providers: {
|
||||
local: "محلي",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
voice: "الصوت",
|
||||
save: "حفظ",
|
||||
retry: "إعادة المحاولة",
|
||||
cancel: "إلغاء",
|
||||
model: "النموذج",
|
||||
language: "اللغة",
|
||||
languagePlaceholder: "اكتشاف تلقائي",
|
||||
setBy: "محدد بواسطة {{name}}",
|
||||
unknownProvider: "حدّث التطبيق لإعداد {{provider}}",
|
||||
sites: {
|
||||
dictationStt: "تحويل الكلام إلى نص",
|
||||
voiceModeStt: "الاستماع",
|
||||
voiceModeTts: "التحدث",
|
||||
},
|
||||
dictation: {
|
||||
title: "الإملاء",
|
||||
enabled: "الإملاء",
|
||||
enabledHint: "تحدّث في مربع الرسالة بدلًا من الكتابة",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "وضع الصوت",
|
||||
info: "يحوّل وضع الصوت كلامك إلى نص ويقرأ ردود الوكيل بصوت عالٍ.",
|
||||
enabled: "وضع الصوت",
|
||||
enabledHint: "تحدّث مع وكلائك دون استخدام يديك",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "مفتاح API",
|
||||
apiKeyHint: "يُحفظ على المضيف ولا يُعرض مرة أخرى.",
|
||||
keyPlaceholder: "sk-...",
|
||||
savedKeyPlaceholder: "محفوظ",
|
||||
missingKey: "مفتاح API مفقود",
|
||||
baseUrl: "عنوان URL الأساسي",
|
||||
baseUrlHint: "للخوادم المتوافقة مع OpenAI. اتركه فارغًا لاستخدام OpenAI.",
|
||||
},
|
||||
models: {
|
||||
loading: "جارٍ تحميل النماذج...",
|
||||
loadError: "تعذّر تحميل النماذج المحلية",
|
||||
none: "لا توجد نماذج متاحة",
|
||||
downloaded: "منزّل",
|
||||
inUse: "قيد الاستخدام",
|
||||
recommended: "موصى به",
|
||||
download: "تنزيل",
|
||||
downloadAction: "تنزيل {{label}}",
|
||||
downloading: "جارٍ التنزيل...",
|
||||
downloadingPercent: "جارٍ التنزيل {{percent}}%",
|
||||
extracting: "جارٍ الاستخراج...",
|
||||
delete: "حذف",
|
||||
deleteAction: "حذف {{label}}",
|
||||
deleteTitle: "حذف {{label}}؟",
|
||||
deleteMessage: "سيؤدي هذا إلى تحرير {{size}}. يمكنك تنزيله مرة أخرى لاحقًا.",
|
||||
languages: "لغات {{label}}",
|
||||
moreLanguages: "{{languages}} و{{count}} أخرى",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "عام",
|
||||
sending: "الإرسال",
|
||||
|
||||
@@ -129,8 +129,6 @@ export const en = {
|
||||
sendAndInterrupt: "Send and interrupt",
|
||||
sendAndSteer: "Send and steer",
|
||||
sendMessage: "Send message",
|
||||
longPressToQueue: "Long press to queue",
|
||||
longPressToSendNow: "Long press to send now",
|
||||
queue: "Queue",
|
||||
send: "Send",
|
||||
},
|
||||
@@ -1347,15 +1345,6 @@ export const en = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "Creating workspace…",
|
||||
creatingLabel: "Creating workspace",
|
||||
updateHost: "Update this host to create a workspace with an agent.",
|
||||
chooseProjectHost: "Choose a project and host",
|
||||
projectUnavailable: "Project is not available on the selected host",
|
||||
workspacePending: "Workspace is still being created.",
|
||||
agentPending: "Agent is still being created.",
|
||||
},
|
||||
title: "New workspace",
|
||||
create: "Create",
|
||||
isolation: {
|
||||
@@ -2296,7 +2285,6 @@ export const en = {
|
||||
connections: "Connections",
|
||||
agents: "Agents",
|
||||
metadata: "Metadata",
|
||||
voice: "Voice",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "Usage",
|
||||
@@ -2381,69 +2369,6 @@ export const en = {
|
||||
docs: "Docs",
|
||||
saveError: "Unable to update metadata generation",
|
||||
},
|
||||
voice: {
|
||||
offline: "Connect to this host to configure voice.",
|
||||
updateHost: "Update the host to configure voice.",
|
||||
saveError: "Unable to update voice settings",
|
||||
provider: "Provider",
|
||||
providers: {
|
||||
local: "Local",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
voice: "Voice",
|
||||
save: "Save",
|
||||
retry: "Retry",
|
||||
cancel: "Cancel",
|
||||
model: "Model",
|
||||
language: "Language",
|
||||
languagePlaceholder: "Auto-detect",
|
||||
setBy: "Set by {{name}}",
|
||||
unknownProvider: "Update the app to configure {{provider}}",
|
||||
sites: {
|
||||
dictationStt: "Speech-to-text",
|
||||
voiceModeStt: "Listening",
|
||||
voiceModeTts: "Speaking",
|
||||
},
|
||||
dictation: {
|
||||
title: "Dictation",
|
||||
enabled: "Dictation",
|
||||
enabledHint: "Speak into the message box instead of typing",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Voice mode",
|
||||
info: "Voice mode transcribes what you say and reads agent replies aloud.",
|
||||
enabled: "Voice mode",
|
||||
enabledHint: "Talk with your agents hands-free",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API key",
|
||||
apiKeyHint: "Saved on the host and never shown again.",
|
||||
keyPlaceholder: "sk-...",
|
||||
savedKeyPlaceholder: "Saved",
|
||||
missingKey: "API key missing",
|
||||
baseUrl: "Base URL",
|
||||
baseUrlHint: "For OpenAI-compatible servers. Leave empty to use OpenAI.",
|
||||
},
|
||||
models: {
|
||||
loading: "Loading models...",
|
||||
loadError: "Couldn't load local models",
|
||||
none: "No models available",
|
||||
downloaded: "Downloaded",
|
||||
inUse: "In use",
|
||||
recommended: "Recommended",
|
||||
download: "Download",
|
||||
downloadAction: "Download {{label}}",
|
||||
downloading: "Downloading...",
|
||||
downloadingPercent: "Downloading {{percent}}%",
|
||||
extracting: "Extracting...",
|
||||
delete: "Delete",
|
||||
deleteAction: "Delete {{label}}",
|
||||
deleteTitle: "Delete {{label}}?",
|
||||
deleteMessage: "This frees {{size}}. You can download it again later.",
|
||||
languages: "{{label}} languages",
|
||||
moreLanguages: "{{languages}} and {{count}} more",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Sending",
|
||||
|
||||
@@ -134,8 +134,6 @@ export const es: TranslationResources = {
|
||||
sendAndInterrupt: "Enviar e interrumpir",
|
||||
sendAndSteer: "Enviar y orientar",
|
||||
sendMessage: "enviar mensaje",
|
||||
longPressToQueue: "Mantén pulsado para poner en cola",
|
||||
longPressToSendNow: "Mantén pulsado para enviar ahora",
|
||||
queue: "Cola",
|
||||
send: "Enviar",
|
||||
},
|
||||
@@ -1375,15 +1373,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "Creando espacio de trabajo…",
|
||||
creatingLabel: "Creando espacio de trabajo",
|
||||
updateHost: "Actualiza este host para crear un espacio de trabajo con un agente.",
|
||||
chooseProjectHost: "Elige un proyecto y un host",
|
||||
projectUnavailable: "El proyecto no está disponible en el host seleccionado",
|
||||
workspacePending: "El espacio de trabajo aún se está creando.",
|
||||
agentPending: "El agente aún se está creando.",
|
||||
},
|
||||
title: "Nuevo espacio de trabajo",
|
||||
create: "Crear",
|
||||
isolation: {
|
||||
@@ -2281,7 +2270,6 @@ export const es: TranslationResources = {
|
||||
connections: "Conexiones",
|
||||
agents: "Agents",
|
||||
metadata: "Metadatos",
|
||||
voice: "Voz",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Proveedores",
|
||||
usage: "Uso",
|
||||
@@ -2304,69 +2292,6 @@ export const es: TranslationResources = {
|
||||
docs: "Documentación",
|
||||
saveError: "No se pudo actualizar la generación de metadatos",
|
||||
},
|
||||
voice: {
|
||||
offline: "Conéctate a este host para configurar la voz.",
|
||||
updateHost: "Actualiza el host para configurar la voz.",
|
||||
saveError: "No se pudo actualizar la configuración de voz",
|
||||
provider: "Proveedor",
|
||||
providers: {
|
||||
local: "Local",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
voice: "Voz",
|
||||
save: "Guardar",
|
||||
retry: "Reintentar",
|
||||
cancel: "Cancelar",
|
||||
model: "Modelo",
|
||||
language: "Idioma",
|
||||
languagePlaceholder: "Detección automática",
|
||||
setBy: "Definido por {{name}}",
|
||||
unknownProvider: "Actualiza la app para configurar {{provider}}",
|
||||
sites: {
|
||||
dictationStt: "Voz a texto",
|
||||
voiceModeStt: "Escucha",
|
||||
voiceModeTts: "Habla",
|
||||
},
|
||||
dictation: {
|
||||
title: "Dictado",
|
||||
enabled: "Dictado",
|
||||
enabledHint: "Habla en el cuadro de mensaje en lugar de escribir",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Modo de voz",
|
||||
info: "El modo de voz transcribe lo que dices y lee en voz alta las respuestas del agente.",
|
||||
enabled: "Modo de voz",
|
||||
enabledHint: "Habla con tus agentes sin usar las manos",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "Clave de API",
|
||||
apiKeyHint: "Se guarda en el host y no se vuelve a mostrar.",
|
||||
keyPlaceholder: "sk-...",
|
||||
savedKeyPlaceholder: "Guardada",
|
||||
missingKey: "Falta la clave de API",
|
||||
baseUrl: "URL base",
|
||||
baseUrlHint: "Para servidores compatibles con OpenAI. Déjala vacía para usar OpenAI.",
|
||||
},
|
||||
models: {
|
||||
loading: "Cargando modelos...",
|
||||
loadError: "No se pudieron cargar los modelos locales",
|
||||
none: "No hay modelos disponibles",
|
||||
downloaded: "Descargado",
|
||||
inUse: "En uso",
|
||||
recommended: "Recomendado",
|
||||
download: "Descargar",
|
||||
downloadAction: "Descargar {{label}}",
|
||||
downloading: "Descargando...",
|
||||
downloadingPercent: "Descargando {{percent}} %",
|
||||
extracting: "Extrayendo...",
|
||||
delete: "Eliminar",
|
||||
deleteAction: "Eliminar {{label}}",
|
||||
deleteTitle: "¿Eliminar {{label}}?",
|
||||
deleteMessage: "Esto libera {{size}}. Puedes volver a descargarlo más tarde.",
|
||||
languages: "Idiomas de {{label}}",
|
||||
moreLanguages: "{{languages}} y {{count}} más",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Envío",
|
||||
|
||||
@@ -135,8 +135,6 @@ export const fr: TranslationResources = {
|
||||
sendAndInterrupt: "Envoyer et interrompre",
|
||||
sendAndSteer: "Envoyer et réorienter",
|
||||
sendMessage: "Envoyer le message",
|
||||
longPressToQueue: "Appui long pour mettre en file d’attente",
|
||||
longPressToSendNow: "Appui long pour envoyer maintenant",
|
||||
queue: "Mettre en file d’attente",
|
||||
send: "Envoyer",
|
||||
},
|
||||
@@ -1369,15 +1367,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "Création de l’espace de travail…",
|
||||
creatingLabel: "Création de l’espace de travail",
|
||||
updateHost: "Mettez cet hôte à jour pour créer un espace de travail avec un agent.",
|
||||
chooseProjectHost: "Choisissez un projet et un hôte",
|
||||
projectUnavailable: "Le projet n’est pas disponible sur l’hôte sélectionné",
|
||||
workspacePending: "L’espace de travail est en cours de création.",
|
||||
agentPending: "L’agent est en cours de création.",
|
||||
},
|
||||
title: "Nouvel espace de travail",
|
||||
create: "Créer",
|
||||
isolation: {
|
||||
@@ -2281,7 +2270,6 @@ export const fr: TranslationResources = {
|
||||
connections: "Connexions",
|
||||
agents: "Agents",
|
||||
metadata: "Métadonnées",
|
||||
voice: "Voix",
|
||||
workspaces: "Espaces de travail",
|
||||
providers: "Fournisseurs",
|
||||
usage: "Utilisation",
|
||||
@@ -2304,69 +2292,6 @@ export const fr: TranslationResources = {
|
||||
docs: "Documentation",
|
||||
saveError: "Impossible de mettre à jour la génération de métadonnées",
|
||||
},
|
||||
voice: {
|
||||
offline: "Connectez-vous à cet hôte pour configurer la voix.",
|
||||
updateHost: "Mettez à jour l'hôte pour configurer la voix.",
|
||||
saveError: "Impossible de mettre à jour les réglages vocaux",
|
||||
provider: "Fournisseur",
|
||||
providers: {
|
||||
local: "Local",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
voice: "Voix",
|
||||
save: "Enregistrer",
|
||||
retry: "Réessayer",
|
||||
cancel: "Annuler",
|
||||
model: "Modèle",
|
||||
language: "Langue",
|
||||
languagePlaceholder: "Détection automatique",
|
||||
setBy: "Défini par {{name}}",
|
||||
unknownProvider: "Mettez à jour l'app pour configurer {{provider}}",
|
||||
sites: {
|
||||
dictationStt: "Transcription",
|
||||
voiceModeStt: "Écoute",
|
||||
voiceModeTts: "Parole",
|
||||
},
|
||||
dictation: {
|
||||
title: "Dictée",
|
||||
enabled: "Dictée",
|
||||
enabledHint: "Parlez dans la zone de message au lieu de taper",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Mode vocal",
|
||||
info: "Le mode vocal transcrit ce que vous dites et lit à voix haute les réponses de l'agent.",
|
||||
enabled: "Mode vocal",
|
||||
enabledHint: "Parlez à vos agents sans les mains",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "Clé d'API",
|
||||
apiKeyHint: "Enregistrée sur l'hôte, jamais réaffichée.",
|
||||
keyPlaceholder: "sk-...",
|
||||
savedKeyPlaceholder: "Enregistrée",
|
||||
missingKey: "Clé d'API manquante",
|
||||
baseUrl: "URL de base",
|
||||
baseUrlHint: "Pour les serveurs compatibles OpenAI. Laissez vide pour utiliser OpenAI.",
|
||||
},
|
||||
models: {
|
||||
loading: "Chargement des modèles...",
|
||||
loadError: "Impossible de charger les modèles locaux",
|
||||
none: "Aucun modèle disponible",
|
||||
downloaded: "Téléchargé",
|
||||
inUse: "Utilisé",
|
||||
recommended: "Recommandé",
|
||||
download: "Télécharger",
|
||||
downloadAction: "Télécharger {{label}}",
|
||||
downloading: "Téléchargement...",
|
||||
downloadingPercent: "Téléchargement {{percent}} %",
|
||||
extracting: "Extraction...",
|
||||
delete: "Supprimer",
|
||||
deleteAction: "Supprimer {{label}}",
|
||||
deleteTitle: "Supprimer {{label}} ?",
|
||||
deleteMessage: "Cela libère {{size}}. Vous pourrez le retélécharger plus tard.",
|
||||
languages: "Langues de {{label}}",
|
||||
moreLanguages: "{{languages}} et {{count}} autres",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "Général",
|
||||
sending: "Envoi",
|
||||
|
||||
@@ -134,8 +134,6 @@ export const ja: TranslationResources = {
|
||||
sendAndInterrupt: "送信して中断",
|
||||
sendAndSteer: "送信して指示を追加",
|
||||
sendMessage: "メッセージを送信",
|
||||
longPressToQueue: "長押しでキューに追加",
|
||||
longPressToSendNow: "長押しで今すぐ送信",
|
||||
queue: "キュー",
|
||||
send: "送信",
|
||||
},
|
||||
@@ -1353,15 +1351,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "ワークスペースを作成中…",
|
||||
creatingLabel: "ワークスペースを作成中",
|
||||
updateHost: "エージェント付きのワークスペースを作成するには、このホストを更新してください。",
|
||||
chooseProjectHost: "プロジェクトとホストを選択してください",
|
||||
projectUnavailable: "選択したホストでプロジェクトを利用できません",
|
||||
workspacePending: "ワークスペースはまだ作成中です。",
|
||||
agentPending: "エージェントはまだ作成中です。",
|
||||
},
|
||||
title: "新しいワークスペース",
|
||||
create: "作成",
|
||||
isolation: {
|
||||
@@ -2254,7 +2243,6 @@ export const ja: TranslationResources = {
|
||||
connections: "接続",
|
||||
agents: "エージェント",
|
||||
metadata: "メタデータ",
|
||||
voice: "音声",
|
||||
workspaces: "ワークスペース",
|
||||
providers: "プロバイダー",
|
||||
usage: "使用状況",
|
||||
@@ -2277,69 +2265,6 @@ export const ja: TranslationResources = {
|
||||
docs: "ドキュメント",
|
||||
saveError: "メタデータ生成を更新できません",
|
||||
},
|
||||
voice: {
|
||||
offline: "音声を設定するには、このホストに接続してください。",
|
||||
updateHost: "音声を設定するには、ホストを更新してください。",
|
||||
saveError: "音声設定を更新できませんでした",
|
||||
provider: "プロバイダー",
|
||||
providers: {
|
||||
local: "ローカル",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
voice: "ボイス",
|
||||
save: "保存",
|
||||
retry: "再試行",
|
||||
cancel: "キャンセル",
|
||||
model: "モデル",
|
||||
language: "言語",
|
||||
languagePlaceholder: "自動検出",
|
||||
setBy: "{{name}} で設定",
|
||||
unknownProvider: "{{provider}} を設定するにはアプリを更新してください",
|
||||
sites: {
|
||||
dictationStt: "音声入力",
|
||||
voiceModeStt: "聞き取り",
|
||||
voiceModeTts: "読み上げ",
|
||||
},
|
||||
dictation: {
|
||||
title: "音声入力",
|
||||
enabled: "音声入力",
|
||||
enabledHint: "入力する代わりにメッセージ欄に話しかけます",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "ボイスモード",
|
||||
info: "ボイスモードは話した内容を文字起こしし、エージェントの返答を読み上げます。",
|
||||
enabled: "ボイスモード",
|
||||
enabledHint: "ハンズフリーでエージェントと会話します",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API キー",
|
||||
apiKeyHint: "ホストに保存され、再表示されません。",
|
||||
keyPlaceholder: "sk-...",
|
||||
savedKeyPlaceholder: "保存済み",
|
||||
missingKey: "API キーがありません",
|
||||
baseUrl: "ベース URL",
|
||||
baseUrlHint: "OpenAI 互換サーバー用です。OpenAI を使う場合は空欄のままにします。",
|
||||
},
|
||||
models: {
|
||||
loading: "モデルを読み込み中...",
|
||||
loadError: "ローカルモデルを読み込めませんでした",
|
||||
none: "利用できるモデルがありません",
|
||||
downloaded: "ダウンロード済み",
|
||||
inUse: "使用中",
|
||||
recommended: "おすすめ",
|
||||
download: "ダウンロード",
|
||||
downloadAction: "{{label}} をダウンロード",
|
||||
downloading: "ダウンロード中...",
|
||||
downloadingPercent: "ダウンロード中 {{percent}}%",
|
||||
extracting: "展開中...",
|
||||
delete: "削除",
|
||||
deleteAction: "{{label}} を削除",
|
||||
deleteTitle: "{{label}} を削除しますか?",
|
||||
deleteMessage: "{{size}} が解放されます。後で再ダウンロードできます。",
|
||||
languages: "{{label}} の対応言語",
|
||||
moreLanguages: "{{languages}} ほか {{count}} 言語",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "一般",
|
||||
sending: "送信",
|
||||
|
||||
@@ -133,8 +133,6 @@ export const ko: TranslationResources = {
|
||||
sendAndInterrupt: "보내고 중단",
|
||||
sendAndSteer: "보내고 지시 추가",
|
||||
sendMessage: "메시지 보내기",
|
||||
longPressToQueue: "길게 눌러 대기열에 추가",
|
||||
longPressToSendNow: "길게 눌러 지금 보내기",
|
||||
queue: "대기열",
|
||||
send: "보내기",
|
||||
},
|
||||
@@ -1346,15 +1344,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "워크스페이스 생성 중…",
|
||||
creatingLabel: "워크스페이스 생성 중",
|
||||
updateHost: "에이전트가 있는 워크스페이스를 만들려면 이 호스트를 업데이트하세요.",
|
||||
chooseProjectHost: "프로젝트와 호스트를 선택하세요",
|
||||
projectUnavailable: "선택한 호스트에서 프로젝트를 사용할 수 없습니다",
|
||||
workspacePending: "워크스페이스가 아직 생성 중입니다.",
|
||||
agentPending: "에이전트가 아직 생성 중입니다.",
|
||||
},
|
||||
title: "새 워크스페이스",
|
||||
create: "생성",
|
||||
isolation: {
|
||||
@@ -2243,7 +2232,6 @@ export const ko: TranslationResources = {
|
||||
connections: "연결",
|
||||
agents: "에이전트",
|
||||
metadata: "메타데이터",
|
||||
voice: "음성",
|
||||
workspaces: "워크스페이스",
|
||||
providers: "프로바이더",
|
||||
usage: "사용량",
|
||||
@@ -2266,69 +2254,6 @@ export const ko: TranslationResources = {
|
||||
docs: "문서",
|
||||
saveError: "메타데이터 생성을 업데이트할 수 없습니다",
|
||||
},
|
||||
voice: {
|
||||
offline: "음성을 설정하려면 이 호스트에 연결하세요.",
|
||||
updateHost: "음성을 설정하려면 호스트를 업데이트하세요.",
|
||||
saveError: "음성 설정을 업데이트할 수 없습니다",
|
||||
provider: "제공자",
|
||||
providers: {
|
||||
local: "로컬",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
voice: "목소리",
|
||||
save: "저장",
|
||||
retry: "다시 시도",
|
||||
cancel: "취소",
|
||||
model: "모델",
|
||||
language: "언어",
|
||||
languagePlaceholder: "자동 감지",
|
||||
setBy: "{{name}}에서 설정됨",
|
||||
unknownProvider: "{{provider}}을(를) 설정하려면 앱을 업데이트하세요",
|
||||
sites: {
|
||||
dictationStt: "음성 텍스트 변환",
|
||||
voiceModeStt: "듣기",
|
||||
voiceModeTts: "말하기",
|
||||
},
|
||||
dictation: {
|
||||
title: "받아쓰기",
|
||||
enabled: "받아쓰기",
|
||||
enabledHint: "입력하는 대신 메시지 상자에 말하세요",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "음성 모드",
|
||||
info: "음성 모드는 말한 내용을 텍스트로 옮기고 에이전트의 답변을 소리 내어 읽어 줍니다.",
|
||||
enabled: "음성 모드",
|
||||
enabledHint: "손을 쓰지 않고 에이전트와 대화하세요",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API 키",
|
||||
apiKeyHint: "호스트에 저장되며 다시 표시되지 않습니다.",
|
||||
keyPlaceholder: "sk-...",
|
||||
savedKeyPlaceholder: "저장됨",
|
||||
missingKey: "API 키 없음",
|
||||
baseUrl: "기본 URL",
|
||||
baseUrlHint: "OpenAI 호환 서버용입니다. OpenAI를 사용하려면 비워 두세요.",
|
||||
},
|
||||
models: {
|
||||
loading: "모델을 불러오는 중...",
|
||||
loadError: "로컬 모델을 불러올 수 없습니다",
|
||||
none: "사용 가능한 모델 없음",
|
||||
downloaded: "다운로드됨",
|
||||
inUse: "사용 중",
|
||||
recommended: "추천",
|
||||
download: "다운로드",
|
||||
downloadAction: "{{label}} 다운로드",
|
||||
downloading: "다운로드 중...",
|
||||
downloadingPercent: "다운로드 중 {{percent}}%",
|
||||
extracting: "압축 해제 중...",
|
||||
delete: "삭제",
|
||||
deleteAction: "{{label}} 삭제",
|
||||
deleteTitle: "{{label}}을(를) 삭제할까요?",
|
||||
deleteMessage: "{{size}}가 확보됩니다. 나중에 다시 다운로드할 수 있습니다.",
|
||||
languages: "{{label}} 지원 언어",
|
||||
moreLanguages: "{{languages}} 외 {{count}}개",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "일반",
|
||||
sending: "전송",
|
||||
|
||||
@@ -134,8 +134,6 @@ export const ptBR: TranslationResources = {
|
||||
sendAndInterrupt: "Enviar e interromper",
|
||||
sendAndSteer: "Enviar e orientar",
|
||||
sendMessage: "Enviar mensagem",
|
||||
longPressToQueue: "Mantenha pressionado para enfileirar",
|
||||
longPressToSendNow: "Mantenha pressionado para enviar agora",
|
||||
queue: "Fila",
|
||||
send: "Enviar",
|
||||
},
|
||||
@@ -1365,15 +1363,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "Criando espaço de trabalho…",
|
||||
creatingLabel: "Criando espaço de trabalho",
|
||||
updateHost: "Atualize este host para criar um espaço de trabalho com um agente.",
|
||||
chooseProjectHost: "Escolha um projeto e um host",
|
||||
projectUnavailable: "O projeto não está disponível no host selecionado",
|
||||
workspacePending: "O espaço de trabalho ainda está sendo criado.",
|
||||
agentPending: "O agente ainda está sendo criado.",
|
||||
},
|
||||
title: "Novo workspace",
|
||||
create: "Criar",
|
||||
isolation: {
|
||||
@@ -2266,7 +2255,6 @@ export const ptBR: TranslationResources = {
|
||||
connections: "Conexões",
|
||||
agents: "Agentes",
|
||||
metadata: "Metadados",
|
||||
voice: "Voz",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Provedores",
|
||||
usage: "Uso",
|
||||
@@ -2289,69 +2277,6 @@ export const ptBR: TranslationResources = {
|
||||
docs: "Documentação",
|
||||
saveError: "Não foi possível atualizar a geração de metadados",
|
||||
},
|
||||
voice: {
|
||||
offline: "Conecte-se a este host para configurar a voz.",
|
||||
updateHost: "Atualize o host para configurar a voz.",
|
||||
saveError: "Não foi possível atualizar as configurações de voz",
|
||||
provider: "Provedor",
|
||||
providers: {
|
||||
local: "Local",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
voice: "Voz",
|
||||
save: "Salvar",
|
||||
retry: "Tentar novamente",
|
||||
cancel: "Cancelar",
|
||||
model: "Modelo",
|
||||
language: "Idioma",
|
||||
languagePlaceholder: "Detecção automática",
|
||||
setBy: "Definido por {{name}}",
|
||||
unknownProvider: "Atualize o app para configurar {{provider}}",
|
||||
sites: {
|
||||
dictationStt: "Fala para texto",
|
||||
voiceModeStt: "Escuta",
|
||||
voiceModeTts: "Fala",
|
||||
},
|
||||
dictation: {
|
||||
title: "Ditado",
|
||||
enabled: "Ditado",
|
||||
enabledHint: "Fale na caixa de mensagem em vez de digitar",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Modo de voz",
|
||||
info: "O modo de voz transcreve o que você diz e lê em voz alta as respostas do agente.",
|
||||
enabled: "Modo de voz",
|
||||
enabledHint: "Converse com seus agentes sem usar as mãos",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "Chave de API",
|
||||
apiKeyHint: "Salva no host e nunca mostrada novamente.",
|
||||
keyPlaceholder: "sk-...",
|
||||
savedKeyPlaceholder: "Salva",
|
||||
missingKey: "Chave de API ausente",
|
||||
baseUrl: "URL base",
|
||||
baseUrlHint: "Para servidores compatíveis com a OpenAI. Deixe vazio para usar a OpenAI.",
|
||||
},
|
||||
models: {
|
||||
loading: "Carregando modelos...",
|
||||
loadError: "Não foi possível carregar os modelos locais",
|
||||
none: "Nenhum modelo disponível",
|
||||
downloaded: "Baixado",
|
||||
inUse: "Em uso",
|
||||
recommended: "Recomendado",
|
||||
download: "Baixar",
|
||||
downloadAction: "Baixar {{label}}",
|
||||
downloading: "Baixando...",
|
||||
downloadingPercent: "Baixando {{percent}}%",
|
||||
extracting: "Extraindo...",
|
||||
delete: "Excluir",
|
||||
deleteAction: "Excluir {{label}}",
|
||||
deleteTitle: "Excluir {{label}}?",
|
||||
deleteMessage: "Isso libera {{size}}. Você pode baixá-lo de novo depois.",
|
||||
languages: "Idiomas do {{label}}",
|
||||
moreLanguages: "{{languages}} e mais {{count}}",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "Geral",
|
||||
sending: "Envio",
|
||||
|
||||
@@ -134,8 +134,6 @@ export const ru: TranslationResources = {
|
||||
sendAndInterrupt: "Отправить и прервать",
|
||||
sendAndSteer: "Отправить и направить",
|
||||
sendMessage: "Отправить сообщение",
|
||||
longPressToQueue: "Удерживайте, чтобы поставить в очередь",
|
||||
longPressToSendNow: "Удерживайте, чтобы отправить сейчас",
|
||||
queue: "Очередь",
|
||||
send: "Отправить",
|
||||
},
|
||||
@@ -1357,15 +1355,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "Создание рабочей области…",
|
||||
creatingLabel: "Создание рабочей области",
|
||||
updateHost: "Обновите этот хост, чтобы создать рабочую область с агентом.",
|
||||
chooseProjectHost: "Выберите проект и хост",
|
||||
projectUnavailable: "Проект недоступен на выбранном хосте",
|
||||
workspacePending: "Рабочая область ещё создаётся.",
|
||||
agentPending: "Агент ещё создаётся.",
|
||||
},
|
||||
title: "Новое рабочее пространство",
|
||||
create: "Создать",
|
||||
isolation: {
|
||||
@@ -2266,7 +2255,6 @@ export const ru: TranslationResources = {
|
||||
connections: "Подключения",
|
||||
agents: "Агенты",
|
||||
metadata: "Метаданные",
|
||||
voice: "Голос",
|
||||
workspaces: "Рабочие пространства",
|
||||
providers: "Провайдеры",
|
||||
usage: "Использование",
|
||||
@@ -2290,70 +2278,6 @@ export const ru: TranslationResources = {
|
||||
docs: "Документация",
|
||||
saveError: "Не удалось обновить настройки генерации метаданных",
|
||||
},
|
||||
voice: {
|
||||
offline: "Подключитесь к этому хосту, чтобы настроить голос.",
|
||||
updateHost: "Обновите хост, чтобы настроить голос.",
|
||||
saveError: "Не удалось обновить голосовые настройки",
|
||||
provider: "Провайдер",
|
||||
providers: {
|
||||
local: "Локально",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
voice: "Голос",
|
||||
save: "Сохранить",
|
||||
retry: "Повторить",
|
||||
cancel: "Отмена",
|
||||
model: "Модель",
|
||||
language: "Язык",
|
||||
languagePlaceholder: "Автоопределение",
|
||||
setBy: "Задано через {{name}}",
|
||||
unknownProvider: "Обновите приложение, чтобы настроить {{provider}}",
|
||||
sites: {
|
||||
dictationStt: "Распознавание речи",
|
||||
voiceModeStt: "Прослушивание",
|
||||
voiceModeTts: "Озвучивание",
|
||||
},
|
||||
dictation: {
|
||||
title: "Диктовка",
|
||||
enabled: "Диктовка",
|
||||
enabledHint: "Говорите в поле сообщения вместо набора текста",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "Голосовой режим",
|
||||
info: "Голосовой режим расшифровывает вашу речь и зачитывает ответы агента вслух.",
|
||||
enabled: "Голосовой режим",
|
||||
enabledHint: "Общайтесь с агентами без рук",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API-ключ",
|
||||
apiKeyHint: "Хранится на хосте и больше не показывается.",
|
||||
keyPlaceholder: "sk-...",
|
||||
savedKeyPlaceholder: "Сохранён",
|
||||
missingKey: "Нет API-ключа",
|
||||
baseUrl: "Базовый URL",
|
||||
baseUrlHint:
|
||||
"Для серверов, совместимых с OpenAI. Оставьте пустым, чтобы использовать OpenAI.",
|
||||
},
|
||||
models: {
|
||||
loading: "Загрузка моделей...",
|
||||
loadError: "Не удалось загрузить локальные модели",
|
||||
none: "Нет доступных моделей",
|
||||
downloaded: "Скачана",
|
||||
inUse: "Используется",
|
||||
recommended: "Рекомендуется",
|
||||
download: "Скачать",
|
||||
downloadAction: "Скачать {{label}}",
|
||||
downloading: "Скачивание...",
|
||||
downloadingPercent: "Скачивание {{percent}}%",
|
||||
extracting: "Распаковка...",
|
||||
delete: "Удалить",
|
||||
deleteAction: "Удалить {{label}}",
|
||||
deleteTitle: "Удалить {{label}}?",
|
||||
deleteMessage: "Освободится {{size}}. Позже её можно скачать снова.",
|
||||
languages: "Языки {{label}}",
|
||||
moreLanguages: "{{languages}} и ещё {{count}}",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "Основные",
|
||||
sending: "Отправка",
|
||||
|
||||
@@ -133,8 +133,6 @@ export const zhCN: TranslationResources = {
|
||||
sendAndInterrupt: "发送并中断",
|
||||
sendAndSteer: "发送并引导",
|
||||
sendMessage: "发送消息",
|
||||
longPressToQueue: "长按加入队列",
|
||||
longPressToSendNow: "长按立即发送",
|
||||
queue: "排队",
|
||||
send: "发送",
|
||||
},
|
||||
@@ -1328,15 +1326,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
newWorkspace: {
|
||||
creation: {
|
||||
creatingWorkspace: "正在创建工作区…",
|
||||
creatingLabel: "正在创建工作区",
|
||||
updateHost: "请更新此主机,以创建包含代理的工作区。",
|
||||
chooseProjectHost: "请选择项目和主机",
|
||||
projectUnavailable: "所选主机上没有此项目",
|
||||
workspacePending: "工作区仍在创建中。",
|
||||
agentPending: "代理仍在创建中。",
|
||||
},
|
||||
title: "新建 workspace",
|
||||
create: "创建",
|
||||
isolation: {
|
||||
@@ -2211,7 +2200,6 @@ export const zhCN: TranslationResources = {
|
||||
connections: "连接",
|
||||
agents: "Agents",
|
||||
metadata: "元数据",
|
||||
voice: "语音",
|
||||
workspaces: "Workspaces",
|
||||
providers: "Providers",
|
||||
usage: "使用情况",
|
||||
@@ -2233,69 +2221,6 @@ export const zhCN: TranslationResources = {
|
||||
docs: "文档",
|
||||
saveError: "无法更新元数据生成设置",
|
||||
},
|
||||
voice: {
|
||||
offline: "连接到此主机以配置语音。",
|
||||
updateHost: "请更新主机以配置语音。",
|
||||
saveError: "无法更新语音设置",
|
||||
provider: "提供方",
|
||||
providers: {
|
||||
local: "本地",
|
||||
openai: "OpenAI",
|
||||
},
|
||||
voice: "音色",
|
||||
save: "保存",
|
||||
retry: "重试",
|
||||
cancel: "取消",
|
||||
model: "模型",
|
||||
language: "语言",
|
||||
languagePlaceholder: "自动检测",
|
||||
setBy: "由 {{name}} 设置",
|
||||
unknownProvider: "更新应用以配置 {{provider}}",
|
||||
sites: {
|
||||
dictationStt: "语音转文字",
|
||||
voiceModeStt: "聆听",
|
||||
voiceModeTts: "朗读",
|
||||
},
|
||||
dictation: {
|
||||
title: "听写",
|
||||
enabled: "听写",
|
||||
enabledHint: "对着消息框说话,无需打字",
|
||||
},
|
||||
voiceMode: {
|
||||
title: "语音模式",
|
||||
info: "语音模式会转写你说的话,并朗读智能体的回复。",
|
||||
enabled: "语音模式",
|
||||
enabledHint: "免手动与智能体对话",
|
||||
},
|
||||
openai: {
|
||||
apiKey: "API 密钥",
|
||||
apiKeyHint: "保存在主机上,不会再次显示。",
|
||||
keyPlaceholder: "sk-...",
|
||||
savedKeyPlaceholder: "已保存",
|
||||
missingKey: "缺少 API 密钥",
|
||||
baseUrl: "基础 URL",
|
||||
baseUrlHint: "用于兼容 OpenAI 的服务器。留空则使用 OpenAI。",
|
||||
},
|
||||
models: {
|
||||
loading: "正在加载模型...",
|
||||
loadError: "无法加载本地模型",
|
||||
none: "没有可用的模型",
|
||||
downloaded: "已下载",
|
||||
inUse: "使用中",
|
||||
recommended: "推荐",
|
||||
download: "下载",
|
||||
downloadAction: "下载 {{label}}",
|
||||
downloading: "正在下载...",
|
||||
downloadingPercent: "正在下载 {{percent}}%",
|
||||
extracting: "正在解压...",
|
||||
delete: "删除",
|
||||
deleteAction: "删除 {{label}}",
|
||||
deleteTitle: "删除 {{label}}?",
|
||||
deleteMessage: "这将释放 {{size}}。之后可以重新下载。",
|
||||
languages: "{{label}} 支持的语言",
|
||||
moreLanguages: "{{languages}} 等另外 {{count}} 种",
|
||||
},
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
sending: "发送",
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
import { ScrollView, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
|
||||
import { usePathname, useRootNavigationState } from "expo-router";
|
||||
import { useStore } from "zustand";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { createSettingsPresentation } from "./model";
|
||||
|
||||
const selection = createSettingsPresentation();
|
||||
|
||||
// Mount once above the sibling Settings routes so replacements and Back share
|
||||
// one visit, and leaving Settings gives the next workspace entry a fresh default.
|
||||
export function useSettingsRouteEntry(): void {
|
||||
const pathname = usePathname();
|
||||
const navigationState = useRootNavigationState();
|
||||
const routeKey = navigationState?.routes[navigationState.index]?.key;
|
||||
const workspace = useLastWorkspaceSelection();
|
||||
const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceServerId = workspace?.serverId ?? null;
|
||||
useLayoutEffect(() => {
|
||||
if (isWorkspaceHydrated && routeKey) {
|
||||
selection.enterRoute({ key: routeKey, pathname }, workspaceServerId);
|
||||
}
|
||||
}, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]);
|
||||
}
|
||||
|
||||
export function useSettingsHostSelection(): string | null {
|
||||
return useStore(selection.store, (state) => state.selectedServerId);
|
||||
}
|
||||
|
||||
export const selectSettingsHost = selection.selectHost;
|
||||
|
||||
// Sibling routes remount the wide sidebar. Only the current mount owns its
|
||||
// offset: RN Web can deliver its final scroll callback after unmount.
|
||||
export function useSettingsSidebarScroll(enabled: boolean) {
|
||||
const ref = useRef<ScrollView>(null);
|
||||
const owner = useRef<ReturnType<typeof selection.attachSidebar> | null>(null);
|
||||
const restore = useCallback(() => {
|
||||
if (owner.current) ref.current?.scrollTo({ y: owner.current.offset, animated: false });
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
const sidebar = selection.attachSidebar();
|
||||
owner.current = sidebar;
|
||||
restore();
|
||||
return () => {
|
||||
sidebar.detach();
|
||||
owner.current = null;
|
||||
};
|
||||
}, [enabled, restore]);
|
||||
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
owner.current?.remember(event.nativeEvent.contentOffset.y);
|
||||
}, []);
|
||||
return { ref, onScroll, onContentSizeChange: restore, scrollEventThrottle: 16 };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { usePathname, useRootNavigationState } from "expo-router";
|
||||
import { useStore } from "zustand";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { createSettingsSelection } from "./model";
|
||||
|
||||
const selection = createSettingsSelection();
|
||||
|
||||
// Mount once above the sibling Settings routes so replacements and Back share
|
||||
// one visit, and leaving Settings gives the next workspace entry a fresh default.
|
||||
export function useSettingsRouteEntry(): void {
|
||||
const pathname = usePathname();
|
||||
const navigationState = useRootNavigationState();
|
||||
const routeKey = navigationState?.routes[navigationState.index]?.key;
|
||||
const workspace = useLastWorkspaceSelection();
|
||||
const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceServerId = workspace?.serverId ?? null;
|
||||
useLayoutEffect(() => {
|
||||
if (isWorkspaceHydrated && routeKey) {
|
||||
selection.enterRoute({ key: routeKey, pathname }, workspaceServerId);
|
||||
}
|
||||
}, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]);
|
||||
}
|
||||
|
||||
export function useSettingsHostSelection(): string | null {
|
||||
return useStore(selection.store, (state) => state.selectedServerId);
|
||||
}
|
||||
|
||||
export const selectSettingsHost = selection.selectHost;
|
||||
+6
-36
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSettingsPresentation } from "./model";
|
||||
import { createSettingsSelection } from "./model";
|
||||
|
||||
describe("Settings host selection", () => {
|
||||
it("keeps picker choices across sections, replacement and Back", () => {
|
||||
const settings = createSettingsPresentation();
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("workspace-host");
|
||||
settings.selectHost("chosen-host");
|
||||
@@ -27,7 +27,7 @@ describe("Settings host selection", () => {
|
||||
});
|
||||
|
||||
it("applies explicit host links once, including project and plugin screens", () => {
|
||||
const settings = createSettingsPresentation();
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/linked-host/projects/project",
|
||||
@@ -56,7 +56,7 @@ describe("Settings host selection", () => {
|
||||
});
|
||||
|
||||
it("seeds each new visit from the workspace, including a retained settings route", () => {
|
||||
const settings = createSettingsPresentation();
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"first-workspace-host",
|
||||
@@ -77,7 +77,7 @@ describe("Settings host selection", () => {
|
||||
});
|
||||
|
||||
it("does not replay a retained host route after a newer picker choice", () => {
|
||||
const settings = createSettingsPresentation();
|
||||
const settings = createSettingsSelection();
|
||||
const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" };
|
||||
settings.enterRoute(oldRoute, "workspace-host");
|
||||
settings.selectHost("second");
|
||||
@@ -93,40 +93,10 @@ describe("Settings host selection", () => {
|
||||
});
|
||||
|
||||
it("does not reseed a visit when remembered workspace state changes", () => {
|
||||
const settings = createSettingsPresentation();
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, null);
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings sidebar presentation", () => {
|
||||
it("restores across replacements and rejects callbacks from older sidebars", () => {
|
||||
const settings = createSettingsPresentation();
|
||||
settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host");
|
||||
const oldSidebar = settings.attachSidebar();
|
||||
oldSidebar.remember(246);
|
||||
settings.enterRoute({ key: "host", pathname: "/settings/hosts/host/providers" }, "host");
|
||||
const currentSidebar = settings.attachSidebar();
|
||||
expect(currentSidebar.offset).toBe(246);
|
||||
oldSidebar.remember(0);
|
||||
oldSidebar.detach();
|
||||
expect(currentSidebar.offset).toBe(246);
|
||||
currentSidebar.remember(190);
|
||||
currentSidebar.detach();
|
||||
currentSidebar.remember(0);
|
||||
expect(settings.attachSidebar().offset).toBe(190);
|
||||
});
|
||||
|
||||
it("resets on leaving Settings and rejects callbacks from a previous visit", () => {
|
||||
const settings = createSettingsPresentation();
|
||||
settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host");
|
||||
const previousVisit = settings.attachSidebar();
|
||||
previousVisit.remember(246);
|
||||
settings.enterRoute({ key: "workspace", pathname: "/h/host/workspace/workspace" }, "host");
|
||||
settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host");
|
||||
previousVisit.remember(246);
|
||||
expect(settings.attachSidebar().offset).toBe(0);
|
||||
});
|
||||
});
|
||||
+2
-22
@@ -9,28 +9,10 @@ interface SettingsSelectionState {
|
||||
selectedServerId: string | null;
|
||||
}
|
||||
|
||||
export function createSettingsPresentation() {
|
||||
export function createSettingsSelection() {
|
||||
const store = createStore<SettingsSelectionState>(() => ({ selectedServerId: null }));
|
||||
const enteredRoutes = new Map<string, string>();
|
||||
let inSettings = false;
|
||||
let sidebarOffset = 0;
|
||||
let sidebarOwner: object | null = null;
|
||||
|
||||
function attachSidebar() {
|
||||
const owner = {};
|
||||
sidebarOwner = owner;
|
||||
return {
|
||||
get offset() {
|
||||
return sidebarOffset;
|
||||
},
|
||||
remember(offset: number) {
|
||||
if (sidebarOwner === owner) sidebarOffset = offset;
|
||||
},
|
||||
detach() {
|
||||
if (sidebarOwner === owner) sidebarOwner = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function enterRoute(
|
||||
{ key, pathname }: SettingsRouteEntry,
|
||||
@@ -38,8 +20,6 @@ export function createSettingsPresentation() {
|
||||
): void {
|
||||
const isSettings = pathname === "/settings" || pathname.startsWith("/settings/");
|
||||
if (!isSettings) {
|
||||
sidebarOffset = 0;
|
||||
sidebarOwner = null;
|
||||
inSettings = false;
|
||||
enteredRoutes.clear();
|
||||
return;
|
||||
@@ -62,5 +42,5 @@ export function createSettingsPresentation() {
|
||||
store.setState({ selectedServerId: serverId });
|
||||
}
|
||||
|
||||
return { store, enterRoute, selectHost, attachSidebar };
|
||||
return { store, enterRoute, selectHost };
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useAgentCreation } from "@/workspace-creation";
|
||||
import { pendingCreationAgent } from "@/workspace-creation/presentation";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -22,7 +20,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { ComposerDock } from "@/composer/dock";
|
||||
@@ -474,7 +471,6 @@ function AgentPanelContent({
|
||||
);
|
||||
});
|
||||
|
||||
const creation = useAgentCreation(serverId, agentId);
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const daemon = connectionServerId
|
||||
? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null)
|
||||
@@ -488,7 +484,7 @@ function AgentPanelContent({
|
||||
: runtimeConnectionStatus;
|
||||
const lastConnectionError = runtimeLastError;
|
||||
|
||||
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent && !creation)) {
|
||||
if (!resolvedServerId || (!runtimeClient && !hasCachedAgent)) {
|
||||
return (
|
||||
<AgentSessionUnavailableState
|
||||
serverLabel={serverLabel}
|
||||
@@ -551,8 +547,6 @@ function AgentPanelBody({
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
||||
);
|
||||
// A creating agent is known locally before the host can return it.
|
||||
const isCreating = useAgentCreation(serverId, agentId ?? "") !== null;
|
||||
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
||||
const lookupAttemptTokenRef = useRef(0);
|
||||
const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []);
|
||||
@@ -563,7 +557,7 @@ function AgentPanelBody({
|
||||
}, [agentId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId || isCreating) {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentState.id) {
|
||||
@@ -611,16 +605,7 @@ function AgentPanelBody({
|
||||
}
|
||||
setLookupState({ tag: "error", message });
|
||||
});
|
||||
}, [
|
||||
agentId,
|
||||
agentState.id,
|
||||
client,
|
||||
hasSession,
|
||||
isConnected,
|
||||
isCreating,
|
||||
lookupState.tag,
|
||||
serverId,
|
||||
]);
|
||||
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
|
||||
|
||||
if (lookupState.tag === "not_found") {
|
||||
return (
|
||||
@@ -656,7 +641,7 @@ function AgentPanelBody({
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!agent && !isCreating) {
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
@@ -769,7 +754,6 @@ function ChatAgentContent({
|
||||
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
|
||||
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
|
||||
);
|
||||
const creation = useAgentCreation(serverId, agentId ?? "");
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
|
||||
kind: "idle",
|
||||
@@ -827,9 +811,6 @@ function ChatAgentContent({
|
||||
[agentState, projectPlacement],
|
||||
);
|
||||
const continuity = useMemo<AgentScreenContinuity>(() => {
|
||||
if (creation) {
|
||||
return { kind: "optimistic-create", agent: pendingCreationAgent(creation) };
|
||||
}
|
||||
if (!hasActiveCreateHandoff || !agentId) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
@@ -843,7 +824,7 @@ function ChatAgentContent({
|
||||
projectPlacement: agent?.projectPlacement ?? null,
|
||||
},
|
||||
};
|
||||
}, [agent, agentId, creation, hasActiveCreateHandoff, serverId]);
|
||||
}, [agent, agentId, hasActiveCreateHandoff, serverId]);
|
||||
|
||||
const viewState = useAgentScreenStateMachine({
|
||||
routeKey: `${serverId}:${agentId ?? ""}`,
|
||||
@@ -916,7 +897,7 @@ function ChatAgentContent({
|
||||
}, [agentId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId || creation) {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentState.archivedAt) {
|
||||
@@ -997,7 +978,6 @@ function ChatAgentContent({
|
||||
agentState.archivedAt,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
agentId,
|
||||
creation,
|
||||
client,
|
||||
hasSession,
|
||||
isConnected,
|
||||
@@ -1017,8 +997,8 @@ function ChatAgentContent({
|
||||
if (nonReadyView) return nonReadyView;
|
||||
invariant(agentId, "agent id is defined when agent content is ready");
|
||||
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
// Empty while a creating agent waits for the host to choose its directory.
|
||||
const agentCwd = effectiveAgent.cwd;
|
||||
const agentCwd = agentState.cwd;
|
||||
invariant(agentCwd, "agent cwd is defined when agent content is ready");
|
||||
const showHistorySyncOverlay =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
@@ -1151,7 +1131,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
const {
|
||||
textSource,
|
||||
editText,
|
||||
flush,
|
||||
replaceText,
|
||||
textReplacement,
|
||||
attachments,
|
||||
@@ -1165,7 +1144,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
(): AgentInputDraft => ({
|
||||
textSource,
|
||||
editText,
|
||||
flush,
|
||||
replaceText,
|
||||
textReplacement,
|
||||
attachments,
|
||||
@@ -1178,7 +1156,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
[
|
||||
textSource,
|
||||
editText,
|
||||
flush,
|
||||
replaceText,
|
||||
textReplacement,
|
||||
attachments,
|
||||
@@ -1388,7 +1365,9 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
const bottomOverlayControlClearance = hasVisibleComposerTracks
|
||||
? resolveComposerTrackControlClearance(isCompactFormFactor)
|
||||
: 0;
|
||||
const streamItemsRaw = useAgentStreamTail(serverId, agentId);
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||
);
|
||||
const pendingMessageSubmissions = useSessionStore(
|
||||
useShallow((state) =>
|
||||
agentId
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import React, {
|
||||
act,
|
||||
Profiler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
|
||||
@@ -224,69 +220,3 @@ describe("workspace sheet environment", () => {
|
||||
source.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
for (const [runtime, Chat] of [
|
||||
["native", NativeChat],
|
||||
["web", WebChat],
|
||||
] as const) {
|
||||
it(`${runtime} ignores hidden stream chunks and reads the latest transcript on reveal`, async () => {
|
||||
const serverId = `hidden-stream-${runtime}`;
|
||||
const agentId = "background-agent";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null);
|
||||
const profile = {
|
||||
commits: 0,
|
||||
onRender() {
|
||||
profile.commits++;
|
||||
},
|
||||
};
|
||||
const publish = (text: string) => {
|
||||
const item: StreamItem = {
|
||||
id: "message",
|
||||
kind: "assistant_message",
|
||||
text,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
store.setAgentStreamTail(serverId, new Map([[agentId, [item]]]));
|
||||
};
|
||||
function Content() {
|
||||
const items = useAgentStreamTail(serverId, agentId);
|
||||
const [draft] = useState("unsent draft");
|
||||
return (
|
||||
<span>
|
||||
{draft}:{items?.[0]?.kind === "assistant_message" ? items[0].text : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const show = (active: boolean) =>
|
||||
act(() =>
|
||||
root.render(
|
||||
<Profiler id="workspace" onRender={profile.onRender}>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<Chat>
|
||||
<Content />
|
||||
</Chat>
|
||||
</RetainedPanelActivity>
|
||||
</Profiler>,
|
||||
),
|
||||
);
|
||||
try {
|
||||
publish("first chunk");
|
||||
show(true);
|
||||
expect(container.textContent).toBe("unsent draft:first chunk");
|
||||
show(false);
|
||||
const hiddenCommits = profile.commits;
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await act(async () => publish(`chunk ${index}`));
|
||||
}
|
||||
expect(profile.commits).toBe(hiddenCommits);
|
||||
show(true);
|
||||
expect(container.textContent).toBe("unsent draft:chunk 9");
|
||||
await act(async () => publish("live after reveal"));
|
||||
expect(container.textContent).toBe("unsent draft:live after reveal");
|
||||
} finally {
|
||||
act(() => root.render(null));
|
||||
store.clearSession(serverId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@ import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { dispatchComposerAgentMessage } from "@/composer/actions";
|
||||
import { isAgentReadyForPrompts } from "@/workspace-creation";
|
||||
import {
|
||||
createAgentMessageQueueWriter,
|
||||
createMessageSubmissionWriter,
|
||||
} from "@/composer/submission/writer";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store";
|
||||
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -81,8 +77,6 @@ export function useReviewFeedback(input: {
|
||||
await dispatchComposerAgentMessage({
|
||||
client: targetClient,
|
||||
agentId: recipient.agentId,
|
||||
isAgentReady: isAgentReadyForPrompts(serverId, recipient.agentId),
|
||||
queue: createAgentMessageQueueWriter(serverId),
|
||||
text: i18n.t("review.feedback.prompt"),
|
||||
attachments: [attachment],
|
||||
encodeImages: async () => [],
|
||||
|
||||
@@ -21,12 +21,9 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { workspaceCreations } from "@/workspace-creation/runtime";
|
||||
import { hasActiveMessageSubmission } from "@/composer/submission/model";
|
||||
import {
|
||||
HostRuntimeController,
|
||||
HostRuntimeStore,
|
||||
getHostRuntimeStore,
|
||||
readInitialDaemonConnectionHint,
|
||||
type HostRuntimeControllerDeps,
|
||||
type HostRuntimeStorage,
|
||||
@@ -76,8 +73,6 @@ class FakeDaemonClient {
|
||||
Awaited<ReturnType<DaemonClient["fetchAgents"]>> | ReturnType<DaemonClient["fetchAgents"]>
|
||||
> = [];
|
||||
public sentAgentMessages: Array<Parameters<DaemonClient["sendAgentMessage"]>> = [];
|
||||
public createWorkspaceRequests: Array<Parameters<DaemonClient["createWorkspace"]>[0]> = [];
|
||||
public createWorkspaceResponse: ReturnType<DaemonClient["createWorkspace"]> | null = null;
|
||||
public sendAgentMessageFailures: Error[] = [];
|
||||
public sendAgentMessageResponses: Promise<void>[] = [];
|
||||
private agentUpdateListeners = new Set<
|
||||
@@ -124,14 +119,6 @@ class FakeDaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
createWorkspace(
|
||||
request: Parameters<DaemonClient["createWorkspace"]>[0],
|
||||
): ReturnType<DaemonClient["createWorkspace"]> {
|
||||
this.createWorkspaceRequests.push(request);
|
||||
if (!this.createWorkspaceResponse) throw new Error("Unexpected workspace creation");
|
||||
return this.createWorkspaceResponse;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connectCalls += 1;
|
||||
this.setConnectionState({ status: "connected" });
|
||||
@@ -439,23 +426,6 @@ function replicaAgent(snapshot: FetchAgentsEntry["agent"], serverId: string): Ag
|
||||
return { ...normalizeAgentSnapshot(snapshot, serverId), projectPlacement: null };
|
||||
}
|
||||
|
||||
/** Drains only send to an agent the host has published to this client. */
|
||||
function publishAgent(serverId: string, agentId: string): void {
|
||||
const entry = makeFetchAgentsEntry({
|
||||
id: agentId,
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
});
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgents(serverId, new Map([[agentId, replicaAgent(entry.agent, serverId)]]));
|
||||
}
|
||||
|
||||
function queuedTexts(serverId: string, agentId: string): string[] {
|
||||
const queued = useSessionStore.getState().sessions[serverId]?.queuedMessages.get(agentId) ?? [];
|
||||
return queued.map((item) => item.text);
|
||||
}
|
||||
|
||||
function agentPermission(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "codex", name: id, kind: "tool", title: id };
|
||||
}
|
||||
@@ -3198,7 +3168,6 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
publishAgent(host.serverId, "agent");
|
||||
sessionStore.updateSessionServerInfo(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
@@ -3264,7 +3233,6 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
publishAgent(host.serverId, "agent");
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([
|
||||
@@ -3311,7 +3279,6 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
publishAgent(host.serverId, "agent");
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]),
|
||||
@@ -3331,212 +3298,6 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("keeps queued messages in order without sending while the agent is unpublished", async () => {
|
||||
const host = makeHost({ serverId: "srv_unpublished_queue_drain" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_unpublished_queue_drain",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{ id: "first", text: "first follow-up", attachments: [] },
|
||||
{ id: "second", text: "second follow-up", attachments: [] },
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
store.drainQueuedAgentMessage(host.serverId, "agent");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(queuedTexts(host.serverId, "agent")).toEqual(["first follow-up", "second follow-up"]);
|
||||
});
|
||||
expect(fakeClient.sentAgentMessages).toEqual([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("keeps queued messages in place for a published agent whose creation has not completed", async () => {
|
||||
// The host publishes a created agent before it starts the initial prompt.
|
||||
const host = makeHost({ serverId: "srv_creating_queue_drain" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_creating_queue_drain",
|
||||
},
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
const creation = workspaceCreations.submit({
|
||||
serverId: host.serverId,
|
||||
key: "creating-queue-drain",
|
||||
provider: "codex",
|
||||
config: {},
|
||||
projectId: "project",
|
||||
projectName: "Project",
|
||||
text: "initial prompt",
|
||||
prepare: () => new Promise(() => undefined),
|
||||
});
|
||||
publishAgent(host.serverId, creation.agentId);
|
||||
const queued = [
|
||||
{ id: "first", text: "first follow-up", attachments: [] },
|
||||
{ id: "second", text: "second follow-up", attachments: [] },
|
||||
];
|
||||
sessionStore.setQueuedMessages(host.serverId, new Map([[creation.agentId, queued]]));
|
||||
|
||||
store.drainQueuedAgentMessage(host.serverId, creation.agentId);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions[host.serverId]?.queuedMessages.get(creation.agentId),
|
||||
).toBe(queued);
|
||||
expect(fakeClient.sentAgentMessages).toEqual([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("drains a follow-up once when creation completes after the initial turn already ended", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_late_creation_adoption",
|
||||
connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] }));
|
||||
const completion = new Deferred<Awaited<ReturnType<DaemonClient["createWorkspace"]>>>();
|
||||
fakeClient.createWorkspaceResponse = completion.promise;
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
}),
|
||||
getClientId: async () => "cid_late_creation_adoption",
|
||||
},
|
||||
});
|
||||
// Creation and its adoption drain run against the app's runtime.
|
||||
const previousRuntime = Reflect.get(globalThis, "__paseoHostRuntimeStore");
|
||||
Reflect.set(globalThis, "__paseoHostRuntimeStore", store);
|
||||
expect(getHostRuntimeStore()).toBe(store);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
store.syncHosts([host]);
|
||||
await waitForHostOnline(store, host.serverId);
|
||||
await store.refreshAgentDirectory({
|
||||
serverId: host.serverId,
|
||||
subscribe: {},
|
||||
page: { limit: 200 },
|
||||
});
|
||||
// Connecting replaced the server info; the host tracks submitted prompts.
|
||||
useSessionStore.getState().updateSessionServerInfo(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
version: "0.2.6",
|
||||
features: { canonicalSubmittedPrompts: true },
|
||||
});
|
||||
|
||||
const creation = workspaceCreations.submit({
|
||||
serverId: host.serverId,
|
||||
key: "late-creation-adoption",
|
||||
provider: "codex",
|
||||
config: {},
|
||||
projectId: "project",
|
||||
projectName: "Project",
|
||||
text: "initial prompt",
|
||||
prepare: async () => ({
|
||||
source: { kind: "directory", path: "/repo" },
|
||||
agent: { config: { provider: "codex", cwd: "/repo" }, initialPrompt: "initial prompt" },
|
||||
}),
|
||||
});
|
||||
await vi.waitFor(() => expect(fakeClient.createWorkspaceRequests).toHaveLength(1));
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([[creation.agentId, [{ id: "follow-up", text: "follow-up", attachments: [] }]]]),
|
||||
);
|
||||
|
||||
// The host publishes the agent, which runs its initial turn to completion before the
|
||||
// creation response arrives. That turn end finds the agent not ready yet.
|
||||
const published = makeFetchAgentsEntry({
|
||||
id: creation.agentId,
|
||||
cwd: "/repo",
|
||||
updatedAt: "2026-07-12T10:00:00.000Z",
|
||||
});
|
||||
store.acceptWorkspaceSnapshots(host.serverId, [
|
||||
{
|
||||
createdAt: null,
|
||||
lastUserMessageAt: null,
|
||||
id: creation.workspaceId,
|
||||
projectId: "project",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
},
|
||||
]);
|
||||
fakeClient.agentUpdate({
|
||||
kind: "upsert",
|
||||
agent: { ...published.agent, status: "running" },
|
||||
project: published.project,
|
||||
});
|
||||
fakeClient.agentUpdate({ kind: "upsert", agent: published.agent, project: published.project });
|
||||
await Promise.resolve();
|
||||
expect(fakeClient.sentAgentMessages).toEqual([]);
|
||||
expect(queuedTexts(host.serverId, creation.agentId)).toEqual(["follow-up"]);
|
||||
// The initial prompt's local submission has no provider acknowledgement yet.
|
||||
expect(
|
||||
hasActiveMessageSubmission(
|
||||
useSessionStore
|
||||
.getState()
|
||||
.sessions[host.serverId]?.messageSubmissions.get(creation.agentId),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
completion.resolve({ workspace: null, error: null } as Awaited<
|
||||
ReturnType<DaemonClient["createWorkspace"]>
|
||||
>);
|
||||
|
||||
await vi.waitFor(() => expect(fakeClient.sentAgentMessages).toHaveLength(1));
|
||||
await workspaceCreations.settled(creation);
|
||||
expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([
|
||||
[creation.agentId, "follow-up"],
|
||||
]);
|
||||
expect(queuedTexts(host.serverId, creation.agentId)).toEqual([]);
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
Reflect.set(globalThis, "__paseoHostRuntimeStore", previousRuntime);
|
||||
});
|
||||
|
||||
it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => {
|
||||
const host = makeHost({ serverId: "srv_steer_queue_drain" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
@@ -3553,7 +3314,6 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
publishAgent(host.serverId, "agent");
|
||||
sessionStore.setQueuedMessages(
|
||||
host.serverId,
|
||||
new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]),
|
||||
@@ -3582,7 +3342,6 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
const sessionStore = useSessionStore.getState();
|
||||
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
|
||||
publishAgent(host.serverId, "agent");
|
||||
sessionStore.updateSessionServerInfo(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
hostname: null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user