mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e802e92cd | ||
|
|
6f04d33b18 | ||
|
|
1526532ff4 | ||
|
|
dbff649d88 | ||
|
|
a6ad4ff430 | ||
|
|
165fc11955 | ||
|
|
426b81e5d2 | ||
|
|
2c90498cb8 | ||
|
|
c42d48a8ff | ||
|
|
54b1f524b7 | ||
|
|
85db9711b3 | ||
|
|
3c2a98b09b | ||
|
|
9e3ebd9665 | ||
|
|
a1606ea515 | ||
|
|
fa8fab75aa | ||
|
|
a3f350a7dc | ||
|
|
6a8f507d10 | ||
|
|
ea0bd47c1d | ||
|
|
f4dcb3954a | ||
|
|
7fc4dd1e86 | ||
|
|
78bb56d90d | ||
|
|
a788064e32 | ||
|
|
74f0b9da56 | ||
|
|
aa01f23ab3 | ||
|
|
ae3f1bb5df | ||
|
|
2064bac918 | ||
|
|
fa4294a0e1 | ||
|
|
c993858898 | ||
|
|
6e04153b95 | ||
|
|
a6f0d79212 | ||
|
|
4b2e0357aa | ||
|
|
acf88f4aca | ||
|
|
412c66ce4b | ||
|
|
cb906d4223 | ||
|
|
bf0d3b9786 | ||
|
|
7f37255f88 | ||
|
|
5c03e25232 | ||
|
|
b440f44111 | ||
|
|
d433e1a362 | ||
|
|
15a2e3bdcb | ||
|
|
64ba05cea5 | ||
|
|
1521ceb381 | ||
|
|
4338f5b46c | ||
|
|
e8a64fb569 | ||
|
|
cf1f849b68 | ||
|
|
bc0886ad7c | ||
|
|
12fcfe53eb | ||
|
|
a198a33ff5 | ||
|
|
7c85341a42 | ||
|
|
c534c857a4 | ||
|
|
6d8c7c4d2d | ||
|
|
f0d96f8e5a | ||
|
|
f8d4758e6b | ||
|
|
09b498da61 | ||
|
|
221b6665a0 | ||
|
|
94dd36a4c9 | ||
|
|
84c1ff560f | ||
|
|
b7f9092e93 | ||
|
|
ade05607d2 | ||
|
|
6d13796b2d | ||
|
|
191cea47a0 | ||
|
|
0aa4868b31 | ||
|
|
e7016ef6b8 | ||
|
|
4332eca9ca | ||
|
|
fc31d31493 | ||
|
|
8ba71cc189 | ||
|
|
38c1b08433 | ||
|
|
21a5d4f061 | ||
|
|
1daa131480 | ||
|
|
f33a5191df | ||
|
|
d6c3896fd9 | ||
|
|
f2da4b861b | ||
|
|
eec9804567 | ||
|
|
f58cfe9008 | ||
|
|
02adadbbc8 |
@@ -155,6 +155,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
@@ -243,7 +252,12 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
runs-on: ubuntu-latest
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -267,3 +281,5 @@ jobs:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
|
||||
PASEO_CLI_TEST_SHARD_TOTAL: "3"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
@@ -53,6 +56,7 @@
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "interface"],
|
||||
|
||||
"import/no-unassigned-import": [
|
||||
|
||||
+25
-8
@@ -1,18 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.65-beta.5 - 2026-05-03
|
||||
## 0.1.67 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Archiving a worktree or workspace feels instant instead of waiting on the daemon, with automatic rollback if it fails.
|
||||
- The built-in daemon toggle in desktop settings now actually takes effect.
|
||||
- Desktop settings no longer reset on app launch after a legacy migration.
|
||||
- Desktop daemon startup failures now surface on the splash screen and respond to retry, instead of leaving the app silently stuck.
|
||||
- Internal LLM calls (branch names, commit messages, PR text) no longer leave behind ephemeral agent sessions in your provider history.
|
||||
|
||||
## 0.1.66 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Streaming markdown preserves trailing newlines so paragraph spacing stays correct while the agent is still typing.
|
||||
- Agent initialization failures surface within 30 seconds instead of 5 minutes.
|
||||
- Terminals reply to ANSI cursor-position queries, so tools that ask for cursor location no longer hang.
|
||||
|
||||
## 0.1.65 - 2026-05-03
|
||||
|
||||
### Added
|
||||
|
||||
- **In-app browser** — open a real web browser in any workspace and click any element on the page to send it to the agent as context. ([#670](https://github.com/getpaseo/paseo/pull/670) by [@jasonkneen](https://github.com/jasonkneen))
|
||||
- Inline review comments in the git diff pane, including tap a line number to start a comment. ([#530](https://github.com/getpaseo/paseo/pull/530))
|
||||
- Sub-agent activity now streams into the timeline as nested tool calls — Codex sub-agents, OpenCode sub-agents, and Claude sub-agents all show their work inline. ([#672](https://github.com/getpaseo/paseo/pull/672), [#658](https://github.com/getpaseo/paseo/pull/658) by [@thisisryanswift](https://github.com/thisisryanswift))
|
||||
- **In-app browser** — open a real web browser in any workspace to test your app. ([#670](https://github.com/getpaseo/paseo/pull/670) by [@jasonkneen](https://github.com/jasonkneen))
|
||||
- Inline review comments in the git diff pane. Tap a line number to start a comment. ([#530](https://github.com/getpaseo/paseo/pull/530))
|
||||
- Sub-agent activity is now shown for Codex, OpenCode, and Claude. ([#672](https://github.com/getpaseo/paseo/pull/672), [#658](https://github.com/getpaseo/paseo/pull/658) by [@thisisryanswift](https://github.com/thisisryanswift))
|
||||
- Pull and push your branch in one step from the git actions menu in the changes pane.
|
||||
- Resume existing agent sessions with `paseo import --provider <name> <id>`. ([#632](https://github.com/getpaseo/paseo/pull/632))
|
||||
- Connect directly via TCP URI with SSL and optional password auth. ([#635](https://github.com/getpaseo/paseo/pull/635))
|
||||
- Password authentication and SSL support for daemon connections. ([#635](https://github.com/getpaseo/paseo/pull/635))
|
||||
- Connect to a daemon via relay using a pairing offer URL from the CLI. ([#639](https://github.com/getpaseo/paseo/pull/639))
|
||||
- **Windows:** Native ARM64 builds are now available for Snapdragon X / Copilot+ PCs.
|
||||
- Images in assistant messages show a loading spinner while they load and an "Image unavailable" fallback if they fail, instead of a blank space.
|
||||
- **Windows:** Native ARM64 builds are now available.
|
||||
- Bundled Paseo skills now refresh automatically on desktop app launch.
|
||||
|
||||
### Improved
|
||||
@@ -46,7 +63,7 @@
|
||||
- `paseo.json` parse errors in setup, teardown, and terminal actions now surface a clear error instead of failing silently.
|
||||
- Diff gutter line numbers were shifted one row out of alignment in some cases on web.
|
||||
- Streamed agent output reconciles cleanly when the timeline hydrates mid-turn. ([#663](https://github.com/getpaseo/paseo/pull/663))
|
||||
- Browser pane keyboard handling no longer steals shortcuts from the rest of the app.
|
||||
- Images in assistant messages show a loading spinner while they load and an "Image unavailable" fallback if they fail, instead of a blank space.
|
||||
- Isolated bottom sheet modals close and re-open without getting stuck.
|
||||
|
||||
## 0.1.64 - 2026-04-28
|
||||
|
||||
@@ -228,6 +228,8 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
|
||||
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-iiqzJ05xnnXyf5eRPqq9xKztwkaxO389vLZFjAh7IjU=";
|
||||
npmDepsHash = "sha256-14DjNXerk2vLOe7UV0oVo+ifmskwsWpu/jahxrLw4M8=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
Generated
+117
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -30,6 +30,7 @@
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "0.46.0",
|
||||
"oxlint": "1.61.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -10376,6 +10377,90 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-arm64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.22.1.tgz",
|
||||
"integrity": "sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/darwin-x64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.22.1.tgz",
|
||||
"integrity": "sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-arm64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.22.1.tgz",
|
||||
"integrity": "sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/linux-x64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.22.1.tgz",
|
||||
"integrity": "sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-arm64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.22.1.tgz",
|
||||
"integrity": "sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint-tsgolint/win32-x64": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.22.1.tgz",
|
||||
"integrity": "sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.61.0.tgz",
|
||||
@@ -31174,6 +31259,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint-tsgolint": {
|
||||
"version": "0.22.1",
|
||||
"resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.22.1.tgz",
|
||||
"integrity": "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tsgolint": "bin/tsgolint.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint-tsgolint/darwin-arm64": "0.22.1",
|
||||
"@oxlint-tsgolint/darwin-x64": "0.22.1",
|
||||
"@oxlint-tsgolint/linux-arm64": "0.22.1",
|
||||
"@oxlint-tsgolint/linux-x64": "0.22.1",
|
||||
"@oxlint-tsgolint/win32-arm64": "0.22.1",
|
||||
"@oxlint-tsgolint/win32-x64": "0.22.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
@@ -38445,7 +38548,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38571,10 +38674,10 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.65-beta.5",
|
||||
"@getpaseo/server": "0.1.67",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38616,7 +38719,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38665,7 +38768,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -38701,7 +38804,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -38727,7 +38830,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38742,12 +38845,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.65-beta.5",
|
||||
"@getpaseo/relay": "0.1.65-beta.5",
|
||||
"@getpaseo/highlight": "0.1.67",
|
||||
"@getpaseo/relay": "0.1.67",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -38762,6 +38865,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
@@ -39199,7 +39303,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -101,6 +101,7 @@
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "0.46.0",
|
||||
"oxlint": "1.61.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -43,11 +43,10 @@ jobs:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [submit_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
working_directory: ./packages/app
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
working_directory: ./packages/app
|
||||
run: bundle exec fastlane ios submit_review
|
||||
|
||||
@@ -31,7 +31,7 @@ test.beforeAll(async () => {
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to seed workspace");
|
||||
}
|
||||
workspaceId = String(result.workspace.id);
|
||||
workspaceId = result.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
|
||||
// Extend base test to provide dynamic baseURL from global-setup
|
||||
const test = base.extend({
|
||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{ paseoE2ESetup: void }>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -10,102 +14,94 @@ const test = base.extend({
|
||||
}
|
||||
await provide(`http://localhost:${metroPort}`);
|
||||
},
|
||||
});
|
||||
|
||||
const consoleEntries = new WeakMap<Page, string[]>();
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
const entries: string[] = [];
|
||||
consoleEntries.set(page, entries);
|
||||
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
const entries: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
|
||||
await provide();
|
||||
|
||||
if (entries.length > 0 && testInfo.status !== testInfo.expectedStatus) {
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
const entries = consoleEntries.get(page);
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (testInfo.status === testInfo.expectedStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
export { test, expect, type Page };
|
||||
|
||||
@@ -140,7 +140,6 @@ class StartupAssertions {
|
||||
|
||||
async expectsReconnectWelcome(): Promise<this> {
|
||||
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
|
||||
|
||||
@@ -204,8 +204,7 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
|
||||
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
|
||||
const originalWrite = next.write.bind(next);
|
||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||
const text =
|
||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
probe.writeCount += 1;
|
||||
const preview = text
|
||||
.replaceAll("\u001b", "\\x1b")
|
||||
@@ -424,7 +423,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
const count = Math.min(from.length, to.length);
|
||||
const values: number[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
values.push(to[index]!.at - from[index]!.at);
|
||||
values.push(to[index].at - from[index].at);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -459,7 +458,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
const keydownToInputFrame = this.keydowns
|
||||
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
|
||||
.filter((at): at is number => at !== null)
|
||||
.map((at, index) => at - this.keydowns[index]!.at);
|
||||
.map((at, index) => at - this.keydowns[index].at);
|
||||
const inputFrameToOutputFrame = this.inputFrames
|
||||
.map((input) => {
|
||||
const output = firstAtOrAfter(this.outputFrames, input.at);
|
||||
@@ -663,8 +662,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
|
||||
const originalWrite = next.write.bind(next);
|
||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||
const text =
|
||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
const event: XtermWriteEvent = {
|
||||
at: performance.now(),
|
||||
committedAt: null,
|
||||
|
||||
@@ -265,7 +265,7 @@ export async function createWorkspaceThroughDaemon(
|
||||
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
id: result.workspace.id,
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
@@ -291,7 +291,7 @@ export async function findWorktreeWorkspaceForProject(
|
||||
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||
}
|
||||
return {
|
||||
id: String(workspace.id),
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
@@ -308,7 +308,7 @@ export async function fetchWorkspaceById(
|
||||
projectRootPath: string;
|
||||
}> {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
|
||||
const workspace = payload.entries.find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,35 @@ interface TempRepo {
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function configureRemote(input: {
|
||||
repoPath: string;
|
||||
withRemote: boolean;
|
||||
originUrl: string | undefined;
|
||||
}): Promise<void> {
|
||||
const { repoPath, withRemote, originUrl } = input;
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||
return;
|
||||
}
|
||||
if (originUrl) {
|
||||
// Daemon reads origin for project grouping; no fetch occurs, so a synthetic URL is fine.
|
||||
execSync(`git remote add origin ${JSON.stringify(originUrl)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const createTempGitRepo = async (
|
||||
prefix = "paseo-e2e-",
|
||||
options?: {
|
||||
withRemote?: boolean;
|
||||
originUrl?: string;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
branches?: string[];
|
||||
@@ -74,14 +99,7 @@ export const createTempGitRepo = async (
|
||||
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
await configureRemote({ repoPath, withRemote, originUrl: options?.originUrl });
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
|
||||
@@ -32,7 +32,7 @@ test.beforeAll(async () => {
|
||||
seedClient = await connectTerminalClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
||||
workspaceId = String(result.workspace.id);
|
||||
workspaceId = result.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ProjectsSettingsProject {
|
||||
|
||||
interface ProjectsSettingsFixtures {
|
||||
editableProject: ProjectsSettingsProject;
|
||||
gitlabRemoteProject: ProjectsSettingsProject;
|
||||
}
|
||||
|
||||
const initialPaseoConfig = {
|
||||
@@ -46,6 +47,22 @@ const test = base.extend<ProjectsSettingsFixtures>({
|
||||
path: repo.path,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
gitlabRemoteProject: async ({ page: _page }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("projects-settings-gitlab-", {
|
||||
paseoConfig: initialPaseoConfig,
|
||||
originUrl: "https://gitlab.com/acme/app.git",
|
||||
});
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await provide({
|
||||
name: openedProject.projectDisplayName,
|
||||
path: repo.path,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
@@ -119,4 +136,16 @@ test.describe("Projects settings", () => {
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
});
|
||||
|
||||
test("user edits worktree setup on a non-GitHub remote project", async ({
|
||||
page,
|
||||
gitlabRemoteProject,
|
||||
}) => {
|
||||
expect(gitlabRemoteProject.name).toBe("acme/app");
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ async function openProjectViaDaemon(
|
||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
id: result.workspace.id,
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ async function measureDaemonBurstEcho(
|
||||
timeoutMs: STRESS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
|
||||
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index] - sentAt);
|
||||
return {
|
||||
inputTextLength: inputText.length,
|
||||
inputFrameCount: sendTimes.length,
|
||||
|
||||
@@ -45,7 +45,7 @@ test.describe("Workspace cwd correctness", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||
await openHomeWithProject(page, repo.path);
|
||||
@@ -95,7 +95,7 @@ test.describe("Workspace cwd correctness", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||
// with direct URL navigation to the 2nd+ workspace.
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe("Workspace lifecycle", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
@@ -77,7 +77,7 @@ test.describe("Workspace lifecycle", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
@@ -120,7 +120,7 @@ test.describe("Workspace lifecycle", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
@@ -170,7 +170,7 @@ test.describe("Workspace lifecycle", () => {
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
@@ -133,31 +133,37 @@ test.describe("Workspace navigation regression", () => {
|
||||
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
|
||||
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
const archiveClient = await connectArchiveTabDaemonClient();
|
||||
const workspaceIds = new Set<string>();
|
||||
const agentIds: string[] = [];
|
||||
const repo = await createTempGitRepo("workspace-reconnect-");
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
|
||||
workspaceIds.add(workspace.workspaceId);
|
||||
|
||||
const agent = await createIdleAgent(archiveClient, {
|
||||
cwd: repo.path,
|
||||
title: `workspace-reconnect-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(agent.id);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: workspace.workspaceId,
|
||||
});
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
|
||||
await daemonGate.drop();
|
||||
await expect(page.getByTestId("workspace-reconnect-banner")).toBeVisible({
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
@@ -172,7 +178,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
label: "host reconnect",
|
||||
});
|
||||
daemonGate.restore();
|
||||
await expect(page.getByTestId("workspace-reconnect-banner")).toHaveCount(0, {
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await monitorReconnect;
|
||||
@@ -184,9 +190,13 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||
}
|
||||
await archiveClient.close().catch(() => undefined);
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
@@ -213,7 +223,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByText(/Connecting to localhost|localhost is offline|Cannot reach localhost/i),
|
||||
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
|
||||
@@ -42,7 +42,7 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-chat-${Date.now()}`,
|
||||
});
|
||||
const workspaceId = String(workspace.id);
|
||||
const workspaceId = workspace.id;
|
||||
|
||||
const wsInfo = await findWorktreeWorkspaceForProject(client, repo.path);
|
||||
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
||||
@@ -78,7 +78,7 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
throw new Error(result.error ?? "Failed to create workspace");
|
||||
}
|
||||
const workspaceDir = result.workspace.workspaceDirectory;
|
||||
const workspaceId = String(result.workspace.id);
|
||||
const workspaceId = result.workspace.id;
|
||||
|
||||
// Navigate to the worktree workspace via sidebar click (direct URL
|
||||
// navigation for freshly created worktree workspaces can race with
|
||||
|
||||
@@ -337,7 +337,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
throw new Error(result.error ?? "Failed to create workspace");
|
||||
}
|
||||
const workspaceDir = result.workspace.workspaceDirectory;
|
||||
const workspaceId = String(result.workspace.id);
|
||||
const workspaceId = result.workspace.id;
|
||||
|
||||
await completed;
|
||||
|
||||
|
||||
@@ -16,11 +16,34 @@ platform :ios do
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
)
|
||||
|
||||
wait_for_build_processing_to_be_complete(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
build_number: build_number.to_s,
|
||||
require "spaceship"
|
||||
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
filepath: ENV.fetch("ASC_KEY_P8"),
|
||||
)
|
||||
app = Spaceship::ConnectAPI::App.find(APP_IDENTIFIER)
|
||||
UI.user_error!("Could not find app #{APP_IDENTIFIER} on App Store Connect") if app.nil?
|
||||
|
||||
deadline = Time.now + (30 * 60)
|
||||
loop do
|
||||
builds = Spaceship::ConnectAPI::Build.all(
|
||||
app_id: app.id,
|
||||
filter: { "version" => build_number.to_s },
|
||||
includes: "preReleaseVersion",
|
||||
)
|
||||
build = builds.find { |b| b.pre_release_version&.platform == "IOS" }
|
||||
state = build&.processing_state
|
||||
UI.message("Build #{build_number} processing state: #{state || 'unknown'}")
|
||||
break if state == "VALID"
|
||||
if state == "INVALID" || state == "FAILED"
|
||||
UI.user_error!("Build #{build_number} failed App Store processing (state: #{state}).")
|
||||
end
|
||||
if Time.now > deadline
|
||||
UI.user_error!("Timed out waiting for build #{build_number} to finish processing on App Store Connect.")
|
||||
end
|
||||
sleep(30)
|
||||
end
|
||||
|
||||
deliver(
|
||||
api_key: api_key,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.65-beta.5",
|
||||
"version": "0.1.67",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env node
|
||||
// Scale the amplitude of the thinking-tone PCM16 base64 in-place.
|
||||
// Usage: node scripts/lower-thinking-tone.mjs <gain>
|
||||
// Example: node scripts/lower-thinking-tone.mjs 0.3 (≈ -10 dB)
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const target = resolve(here, "../src/utils/thinking-tone.native-pcm.ts");
|
||||
|
||||
const gainArg = process.argv[2];
|
||||
if (!gainArg) {
|
||||
console.error("Usage: node scripts/lower-thinking-tone.mjs <gain>");
|
||||
process.exit(1);
|
||||
}
|
||||
const gain = Number(gainArg);
|
||||
if (!Number.isFinite(gain) || gain < 0) {
|
||||
console.error(`Invalid gain: ${gainArg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const source = readFileSync(target, "utf8");
|
||||
const match = source.match(/"([A-Za-z0-9+/=]+)"/);
|
||||
if (!match) {
|
||||
console.error("Could not find base64 string in target file.");
|
||||
process.exit(1);
|
||||
}
|
||||
const originalBase64 = match[1];
|
||||
|
||||
const buf = Buffer.from(originalBase64, "base64");
|
||||
if (buf.byteLength % 2 !== 0) {
|
||||
console.error(`PCM16 buffer has odd length ${buf.byteLength}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let peakBefore = 0;
|
||||
let peakAfter = 0;
|
||||
const out = Buffer.alloc(buf.byteLength);
|
||||
for (let i = 0; i < buf.byteLength; i += 2) {
|
||||
const sample = buf.readInt16LE(i);
|
||||
peakBefore = Math.max(peakBefore, Math.abs(sample));
|
||||
const scaled = Math.max(-32768, Math.min(32767, Math.round(sample * gain)));
|
||||
peakAfter = Math.max(peakAfter, Math.abs(scaled));
|
||||
out.writeInt16LE(scaled, i);
|
||||
}
|
||||
|
||||
const newBase64 = out.toString("base64");
|
||||
const updated = source.replace(originalBase64, newBase64);
|
||||
writeFileSync(target, updated);
|
||||
|
||||
console.log(`Scaled thinking tone by ${gain}.`);
|
||||
console.log(` samples: ${buf.byteLength / 2}`);
|
||||
console.log(` peak abs: ${peakBefore} -> ${peakAfter}`);
|
||||
console.log(` base64 len ${originalBase64.length} -> ${newBase64.length}`);
|
||||
console.log(` wrote ${target}`);
|
||||
@@ -26,7 +26,7 @@ import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-g
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
@@ -50,17 +50,18 @@ import {
|
||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
|
||||
import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
||||
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -312,6 +313,14 @@ function useDaemonStartIsRunning(): boolean {
|
||||
|
||||
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
|
||||
|
||||
async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
if (!shouldUseDesktopDaemon()) {
|
||||
return false;
|
||||
}
|
||||
const settings = await loadDesktopSettings();
|
||||
return settings.daemon.manageBuiltInDaemon;
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const store = getHostRuntimeStore();
|
||||
@@ -319,7 +328,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldUseDesktopDaemon(),
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -346,12 +356,18 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
void getDaemonStartService({ store: getHostRuntimeStore() }).start();
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
|
||||
const storeReady =
|
||||
const isCurrentlyStoreReady =
|
||||
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
|
||||
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
|
||||
|
||||
const state = useMemo<HostRuntimeBootstrapState>(
|
||||
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
|
||||
@@ -393,7 +409,6 @@ function AppContainer({
|
||||
selectedAgentId,
|
||||
chromeEnabled: chromeEnabledOverride,
|
||||
}: AppContainerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
@@ -407,7 +422,7 @@ function AppContainer({
|
||||
const cycleTheme = useCallback(() => {
|
||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
||||
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
|
||||
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex] });
|
||||
}, [settings.theme, updateSettings]);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
@@ -450,13 +465,8 @@ function AppContainer({
|
||||
|
||||
useActiveWorktreeNewAction();
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
@@ -594,9 +604,6 @@ function MobileGestureWrapper({
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const systemColorScheme = useColorScheme();
|
||||
const { theme } = useUnistyles();
|
||||
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
useEffect(() => {
|
||||
@@ -609,21 +616,9 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [settingsLoading, settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoading || isNative) {
|
||||
return;
|
||||
}
|
||||
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: theme.colors.surface0,
|
||||
foregroundColor: theme.colors.foreground,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [settingsLoading, resolvedTheme, theme.colors.foreground, theme.colors.surface0]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<DesktopWindowControlsSync enabled={!settingsLoading} />
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
@@ -632,6 +627,24 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
|
||||
const { theme } = useUnistyles();
|
||||
const surface0 = theme.colors.surface0;
|
||||
const foreground = theme.colors.foreground;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || isNative) return;
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: surface0,
|
||||
foregroundColor: foreground,
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [enabled, surface0, foreground]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function OfferLinkListener({
|
||||
upsertDaemonFromOfferUrl,
|
||||
}: {
|
||||
@@ -772,7 +785,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||
return;
|
||||
}
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
||||
router.replace(mapPathnameToServer(pathname, hosts[0].serverId));
|
||||
}, [activeServerId, hosts, pathname, router]);
|
||||
|
||||
// Parse selectedAgentKey directly from pathname
|
||||
@@ -911,20 +924,23 @@ function RootProviders({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const { theme } = useUnistyles();
|
||||
const gestureRootStyle = useMemo(
|
||||
() => ({ flex: 1, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={gestureRootStyle}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
</RuntimeProviders>
|
||||
</RootProviders>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const layoutStyles = StyleSheet.create((theme) => ({
|
||||
surfaceFill: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -55,6 +55,42 @@ describe("startHostRuntimeBootstrap", () => {
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips daemon-start when the startup gate resolves false", async () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: async () => false,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.boot).toHaveBeenCalledTimes(1);
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces gate rejection to onGateError without starting the daemon", async () => {
|
||||
const store = createFakeStore();
|
||||
const daemonStartService = createFakeDaemonStartService();
|
||||
const onGateError = vi.fn();
|
||||
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: async () => {
|
||||
throw new Error("settings file unreadable");
|
||||
},
|
||||
onGateError,
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(onGateError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(daemonStartService.start).not.toHaveBeenCalled();
|
||||
expect(onGateError).toHaveBeenCalledWith(expect.stringContaining("settings file unreadable"));
|
||||
});
|
||||
|
||||
it("does not await the daemon-start promise", () => {
|
||||
const store = createFakeStore();
|
||||
let resolveStart: ((value: { ok: true }) => void) | undefined;
|
||||
|
||||
@@ -11,17 +11,49 @@ export interface HostRuntimeBootstrapDaemonStartService {
|
||||
start: () => Promise<DaemonStartResult>;
|
||||
}
|
||||
|
||||
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
|
||||
|
||||
export interface StartHostRuntimeBootstrapInput {
|
||||
store: HostRuntimeBootstrapStore;
|
||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||
shouldStartDaemon: boolean;
|
||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
||||
onGateError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
|
||||
input.store.boot();
|
||||
if (input.shouldStartDaemon) {
|
||||
void input.daemonStartService.start();
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService: input.daemonStartService,
|
||||
shouldStartDaemon: input.shouldStartDaemon,
|
||||
onGateError: input.onGateError,
|
||||
});
|
||||
}
|
||||
|
||||
export function startDaemonIfGateAllows(input: {
|
||||
daemonStartService: HostRuntimeBootstrapDaemonStartService;
|
||||
shouldStartDaemon: HostRuntimeBootstrapStartGate;
|
||||
onGateError?: (message: string) => void;
|
||||
}): void {
|
||||
const gate = input.shouldStartDaemon;
|
||||
if (typeof gate === "boolean") {
|
||||
if (gate) {
|
||||
void input.daemonStartService.start();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => gate())
|
||||
.then((shouldStartDaemon) => {
|
||||
if (shouldStartDaemon) {
|
||||
void input.daemonStartService.start();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
input.onGateError?.(`Failed to evaluate desktop daemon settings: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
export const WELCOME_ROUTE: Href = "/welcome";
|
||||
|
||||
@@ -9,8 +9,12 @@ import type {
|
||||
} from "@/attachments/types";
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { useClearReviewDraft } from "@/review/store";
|
||||
|
||||
interface WorkspaceAttachmentBindingInput {
|
||||
@@ -69,31 +73,6 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
});
|
||||
}
|
||||
|
||||
function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
}
|
||||
|
||||
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
);
|
||||
}
|
||||
|
||||
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `Browser element · ${attachment.attachment.tag}`,
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||
return (
|
||||
<WorkspaceAttachmentPill
|
||||
@@ -303,7 +282,7 @@ function WorkspaceAttachmentPill({
|
||||
export const composerWorkspaceAttachment = {
|
||||
is: isWorkspaceAttachment,
|
||||
renderPill,
|
||||
toSubmitAttachment,
|
||||
toSubmitAttachment: workspaceAttachmentToSubmitAttachment,
|
||||
userAttachmentsOnly,
|
||||
useBinding: useWorkspaceAttachmentBinding,
|
||||
};
|
||||
|
||||
@@ -22,9 +22,9 @@ class FakeRequest<T = unknown> {
|
||||
class FakeObjectStore {
|
||||
constructor(private readonly onPut: (record: unknown) => void) {}
|
||||
|
||||
put(record: unknown): FakeRequest<unknown> {
|
||||
put(record: unknown): FakeRequest {
|
||||
this.onPut(record);
|
||||
const request = new FakeRequest<unknown>();
|
||||
const request = new FakeRequest();
|
||||
queueMicrotask(() => request.emit("success"));
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ function runTx<T>(
|
||||
const request = run(store);
|
||||
|
||||
request.addEventListener("success", () => {
|
||||
resolve(request.result as T);
|
||||
resolve(request.result);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
|
||||
export function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
}
|
||||
|
||||
export function userAttachmentsOnly(
|
||||
attachments: readonly ComposerAttachment[],
|
||||
): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
);
|
||||
}
|
||||
|
||||
export function workspaceAttachmentToSubmitAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
): AgentAttachment | null {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `Browser element · ${attachment.attachment.tag}`,
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
@@ -234,7 +234,7 @@ function buildFallbackAllProviderModels(
|
||||
map.set(
|
||||
provider,
|
||||
modelOptions.map((option) => ({
|
||||
provider: provider as AgentProvider,
|
||||
provider: provider,
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
})),
|
||||
|
||||
@@ -461,7 +461,7 @@ export function BrowserPane({
|
||||
};
|
||||
const handleFaviconUpdated = (event: Event) => {
|
||||
const favicons = Array.isArray((event as Event & { favicons?: unknown[] }).favicons)
|
||||
? (((event as Event & { favicons?: string[] }).favicons as string[] | undefined) ?? [])
|
||||
? ((event as Event & { favicons?: string[] }).favicons ?? [])
|
||||
: [];
|
||||
updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null });
|
||||
};
|
||||
|
||||
@@ -63,8 +63,8 @@ describe("combined model selector helpers", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
||||
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
||||
expect(matchesSearch(rows[0], "claude")).toBe(true);
|
||||
expect(matchesSearch(rows[1], "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
|
||||
@@ -622,7 +622,7 @@ export function CombinedModelSelector({
|
||||
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||
const providers = Array.from(allProviderModels.keys());
|
||||
if (providers.length !== 1) return null;
|
||||
const providerId = providers[0]!;
|
||||
const providerId = providers[0];
|
||||
const label = resolveProviderLabel(providerDefinitions, providerId);
|
||||
return { kind: "provider", providerId, providerLabel: label };
|
||||
}, [allProviderModels, providerDefinitions]);
|
||||
@@ -655,7 +655,7 @@ export function CombinedModelSelector({
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId);
|
||||
onSelect(provider, modelId);
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
},
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
editQueuedComposerMessage,
|
||||
findGithubItemByOption,
|
||||
isAttachmentSelectedForGithubItem,
|
||||
openComposerAttachment,
|
||||
pickAndPersistImages,
|
||||
queueComposerMessage,
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
type AgentStreamWriter,
|
||||
type AttachmentPersister,
|
||||
type ComposerCancelClient,
|
||||
type ComposerSendClient,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "./composer-actions";
|
||||
|
||||
const imageMetadata: AttachmentMetadata = {
|
||||
id: "img-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "img-1",
|
||||
fileName: "img-1.png",
|
||||
byteSize: 42,
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const issueItem: GitHubSearchItem = {
|
||||
kind: "issue",
|
||||
number: 101,
|
||||
title: "Fix composer attachments",
|
||||
url: "https://github.com/acme/paseo/issues/101",
|
||||
state: "open",
|
||||
body: "Issue body",
|
||||
labels: ["composer"],
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
};
|
||||
|
||||
const prItem: GitHubSearchItem = {
|
||||
kind: "pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
state: "open",
|
||||
body: "PR body",
|
||||
labels: ["composer"],
|
||||
baseRefName: "main",
|
||||
headRefName: "composer-attachments",
|
||||
};
|
||||
|
||||
function imageWithId(id: string): AttachmentMetadata {
|
||||
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
|
||||
}
|
||||
|
||||
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
|
||||
const attachment: Extract<AgentAttachment, { type: "review" }> = {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body,
|
||||
context: {
|
||||
hunkHeader: "@@ -40,2 +40,2 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
kind: "review",
|
||||
reviewDraftKey: `review:${body}`,
|
||||
commentCount: 1,
|
||||
attachment,
|
||||
};
|
||||
}
|
||||
|
||||
function browserElementWorkspaceAttachment(): Extract<
|
||||
WorkspaceComposerAttachment,
|
||||
{ kind: "browser_element" }
|
||||
> {
|
||||
return {
|
||||
kind: "browser_element",
|
||||
attachment: {
|
||||
url: "https://example.com/page",
|
||||
selector: "button.primary",
|
||||
tag: "button",
|
||||
text: "Save",
|
||||
outerHTML: '<button class="primary">Save</button>',
|
||||
computedStyles: { display: "flex" },
|
||||
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
|
||||
reactSource: null,
|
||||
parentChain: ["form.settings"],
|
||||
children: [],
|
||||
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakePersister(): AttachmentPersister & {
|
||||
blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>;
|
||||
fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>;
|
||||
deletedBatches: AttachmentMetadata[][];
|
||||
} {
|
||||
const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = [];
|
||||
const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = [];
|
||||
const deletedBatches: AttachmentMetadata[][] = [];
|
||||
return {
|
||||
blobCalls,
|
||||
fileUriCalls,
|
||||
deletedBatches,
|
||||
persistFromBlob: async ({ blob, mimeType, fileName }) => {
|
||||
blobCalls.push({ blob, mimeType, fileName });
|
||||
return { ...imageMetadata, id: `blob-${blobCalls.length}` };
|
||||
},
|
||||
persistFromFileUri: async ({ uri, mimeType, fileName }) => {
|
||||
fileUriCalls.push({ uri, mimeType, fileName });
|
||||
return { ...imageMetadata, id: `uri-${fileUriCalls.length}` };
|
||||
},
|
||||
deleteAttachments: (metadata) => {
|
||||
deletedBatches.push(metadata);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeSendCall {
|
||||
agentId: string;
|
||||
text: string;
|
||||
options: {
|
||||
messageId: string;
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: AgentAttachment[];
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeSendClient(
|
||||
options: { rejection?: Error } = {},
|
||||
): ComposerSendClient & { calls: FakeSendCall[] } {
|
||||
const calls: FakeSendCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
sendAgentMessage: async (agentId, text, opts) => {
|
||||
calls.push({ agentId, text, options: opts });
|
||||
if (options.rejection) {
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeStream extends AgentStreamWriter {
|
||||
head: Map<string, StreamItem[]>;
|
||||
tail: Map<string, StreamItem[]>;
|
||||
}
|
||||
|
||||
function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): FakeStream {
|
||||
const fake: FakeStream = {
|
||||
head: new Map(initialHead),
|
||||
tail: new Map(),
|
||||
getHead: (agentId) => fake.head.get(agentId),
|
||||
setHead: (updater) => {
|
||||
fake.head = updater(fake.head);
|
||||
},
|
||||
setTail: (updater) => {
|
||||
fake.tail = updater(fake.tail);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function createFakeQueue(
|
||||
initial: Map<string, QueuedComposerMessage[]> = new Map(),
|
||||
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
|
||||
const fake: QueueWriter & { state: Map<string, QueuedComposerMessage[]> } = {
|
||||
state: new Map(initial),
|
||||
read: (agentId) => fake.state.get(agentId) ?? [],
|
||||
write: (updater) => {
|
||||
fake.state = updater(fake.state);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
const passthroughEncodeImages = async (images: AttachmentMetadata[]) =>
|
||||
images.map((image) => ({ data: image.id, mimeType: image.mimeType }));
|
||||
|
||||
describe("cancelComposerAgent", () => {
|
||||
function baseInput(): {
|
||||
client: ComposerCancelClient & { canceledIds: string[] };
|
||||
agentId: string;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
} {
|
||||
const canceledIds: string[] = [];
|
||||
return {
|
||||
client: {
|
||||
canceledIds,
|
||||
cancelAgent: async (id) => {
|
||||
canceledIds.push(id);
|
||||
},
|
||||
},
|
||||
agentId: "agent",
|
||||
isAgentRunning: true,
|
||||
isCancellingAgent: false,
|
||||
isConnected: true,
|
||||
};
|
||||
}
|
||||
|
||||
it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent(input);
|
||||
expect(result).toBe(true);
|
||||
expect(input.client.canceledIds).toEqual(["agent"]);
|
||||
});
|
||||
|
||||
it("does nothing when the agent is not running", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isAgentRunning: false });
|
||||
expect(result).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing when the agent is already being canceled", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
|
||||
expect(result).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing when disconnected or the client is null", () => {
|
||||
const input = baseInput();
|
||||
expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false);
|
||||
expect(cancelComposerAgent({ ...input, client: null })).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickAndPersistImages", () => {
|
||||
it("returns [] when the picker yields nothing", async () => {
|
||||
const persister = createFakePersister();
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => null,
|
||||
persister,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
expect(persister.blobCalls).toEqual([]);
|
||||
expect(persister.fileUriCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists blob sources via persistFromBlob with the picked mime type and file name", async () => {
|
||||
const persister = createFakePersister();
|
||||
const blob = new Blob(["image"]);
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => [
|
||||
{ source: { kind: "blob", blob }, mimeType: "image/png", fileName: "img-1.png" },
|
||||
],
|
||||
persister,
|
||||
});
|
||||
expect(persister.blobCalls).toEqual([{ blob, mimeType: "image/png", fileName: "img-1.png" }]);
|
||||
expect(result.map((m) => m.id)).toEqual(["blob-1"]);
|
||||
});
|
||||
|
||||
it("persists file_uri sources via persistFromFileUri", async () => {
|
||||
const persister = createFakePersister();
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => [
|
||||
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null },
|
||||
],
|
||||
persister,
|
||||
});
|
||||
expect(persister.fileUriCalls).toEqual([
|
||||
{ uri: "/tmp/x.jpg", mimeType: "image/jpeg", fileName: null },
|
||||
]);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const image = imageWithId("img-2");
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "send attachments",
|
||||
attachments: [
|
||||
{ kind: "image", metadata: image },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls).toHaveLength(1);
|
||||
const [call] = client.calls;
|
||||
expect(call.agentId).toBe("agent");
|
||||
expect(call.text).toBe("send attachments");
|
||||
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
|
||||
expect(call.options.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
body: "PR body",
|
||||
baseRefName: "main",
|
||||
headRefName: "composer-attachments",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
const tail = stream.tail.get("agent");
|
||||
expect(tail).toHaveLength(1);
|
||||
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||
expect(userMessage.kind).toBe("user_message");
|
||||
expect(userMessage.text).toBe("send attachments");
|
||||
expect(userMessage.images).toEqual([image]);
|
||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||
expect(userMessage.id).toBe(call.options.messageId);
|
||||
});
|
||||
|
||||
it("appends to the existing head when one is present", async () => {
|
||||
const existingItem: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "prior",
|
||||
text: "prior",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const stream = createFakeStream(new Map([["agent", [existingItem]]]));
|
||||
const client = createFakeSendClient();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "next message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(stream.head.get("agent")).toHaveLength(2);
|
||||
expect(stream.tail.get("agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("submits empty wire arrays when no attachments are provided", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "plain message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options).toMatchObject({
|
||||
images: [],
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes workspace review attachments through the structured attachment path", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const review = reviewWorkspaceAttachment("Please simplify this.");
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "review this",
|
||||
attachments: [review],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||
expect(client.calls[0]?.options.images).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const browserElement = browserElementWorkspaceAttachment();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "inspect element",
|
||||
attachments: [browserElement],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "Browser element · button",
|
||||
text: browserElement.attachment.formatted,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queueComposerMessage", () => {
|
||||
it("queues a trimmed message under the agent id and returns the new entry", () => {
|
||||
const queue = createFakeQueue();
|
||||
const result = queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: " draft ",
|
||||
attachments: [],
|
||||
queue,
|
||||
});
|
||||
|
||||
expect(result.queued?.text).toBe("draft");
|
||||
expect(queue.state.get("agent")).toEqual([
|
||||
{ id: result.queued?.id, text: "draft", attachments: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not queue an empty message with no attachments", () => {
|
||||
const queue = createFakeQueue();
|
||||
const result = queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: " ",
|
||||
attachments: [],
|
||||
queue,
|
||||
});
|
||||
expect(result.queued).toBeNull();
|
||||
expect(queue.state.get("agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("captures workspace review attachments at queue time alongside user attachments", () => {
|
||||
const queue = createFakeQueue();
|
||||
const review = reviewWorkspaceAttachment("Initial queued review.");
|
||||
const image = imageWithId("img-queue");
|
||||
queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: "queue this",
|
||||
attachments: [{ kind: "image", metadata: image }, review],
|
||||
queue,
|
||||
});
|
||||
|
||||
expect(queue.state.get("agent")?.[0]?.attachments).toEqual([
|
||||
{ kind: "image", metadata: image },
|
||||
review,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editQueuedComposerMessage", () => {
|
||||
it("returns null and leaves the queue untouched when the message id is missing", () => {
|
||||
const queue = createFakeQueue(
|
||||
new Map([["agent", [{ id: "other", text: "other", attachments: [] }]]]),
|
||||
);
|
||||
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "missing", queue });
|
||||
expect(result).toBeNull();
|
||||
expect(queue.state.get("agent")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the text and only user attachments, removing the queued entry", () => {
|
||||
const review = reviewWorkspaceAttachment("Queued snapshot.");
|
||||
const image = imageWithId("img-queued-edit");
|
||||
const queue = createFakeQueue(
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{
|
||||
id: "msg-1",
|
||||
text: "queued draft",
|
||||
attachments: [{ kind: "image", metadata: image }, review],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "msg-1", queue });
|
||||
expect(result).toEqual({
|
||||
text: "queued draft",
|
||||
attachments: [{ kind: "image", metadata: image }],
|
||||
});
|
||||
expect(queue.state.get("agent")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendQueuedComposerMessageNow", () => {
|
||||
it("returns missing without submitting when the message id is gone", async () => {
|
||||
const queue = createFakeQueue();
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
submitted.push(input);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "missing" });
|
||||
expect(submitted).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes the queued entry and submits its text + attachments", async () => {
|
||||
const review = reviewWorkspaceAttachment("Queued for send.");
|
||||
const queue = createFakeQueue(
|
||||
new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]),
|
||||
);
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
submitted.push(input);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "submitted" });
|
||||
expect(queue.state.get("agent")).toEqual([]);
|
||||
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
|
||||
});
|
||||
|
||||
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
|
||||
const queue = createFakeQueue(
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{ id: "msg-1", text: "first", attachments: [] },
|
||||
{ id: "msg-2", text: "second", attachments: [] },
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "failed", errorMessage: "network down" });
|
||||
const state = queue.state.get("agent");
|
||||
expect(state?.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeComposerAttachmentAtIndex", () => {
|
||||
it("removes an image attachment and asks the persister to delete the underlying metadata", () => {
|
||||
const image = imageWithId("img-remove");
|
||||
const persister = createFakePersister();
|
||||
const next = removeComposerAttachmentAtIndex({
|
||||
attachments: [{ kind: "image", metadata: image }] satisfies UserComposerAttachment[],
|
||||
index: 0,
|
||||
deleteAttachments: persister.deleteAttachments,
|
||||
});
|
||||
expect(next).toEqual([]);
|
||||
expect(persister.deletedBatches).toEqual([[image]]);
|
||||
});
|
||||
|
||||
it("removes a github attachment without scheduling any storage deletes", () => {
|
||||
const persister = createFakePersister();
|
||||
const next = removeComposerAttachmentAtIndex({
|
||||
attachments: [
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
] satisfies UserComposerAttachment[],
|
||||
index: 0,
|
||||
deleteAttachments: persister.deleteAttachments,
|
||||
});
|
||||
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
expect(persister.deletedBatches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openComposerAttachment", () => {
|
||||
it("opens the lightbox for image attachments", () => {
|
||||
const image = imageWithId("img-body");
|
||||
const lightboxCalls: AttachmentMetadata[] = [];
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: { kind: "image", metadata: image },
|
||||
setLightboxMetadata: (metadata) => {
|
||||
lightboxCalls.push(metadata);
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(lightboxCalls).toEqual([image]);
|
||||
expect(externalUrlCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("delegates workspace review attachments to the workspace opener", () => {
|
||||
const review = reviewWorkspaceAttachment("Open me.");
|
||||
const workspaceCalls: ComposerAttachment[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: review,
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: ({ attachment }) => {
|
||||
workspaceCalls.push(attachment);
|
||||
return true;
|
||||
},
|
||||
openExternalUrl: () => {
|
||||
throw new Error("unexpected external url call");
|
||||
},
|
||||
});
|
||||
expect(workspaceCalls).toEqual([review]);
|
||||
});
|
||||
|
||||
it("opens GitHub item URLs through the external url opener", () => {
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: { kind: "github_issue", item: issueItem },
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(externalUrlCalls).toEqual([issueItem.url]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleGithubAttachment", () => {
|
||||
it("appends a GitHub issue when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], issueItem);
|
||||
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||
});
|
||||
|
||||
it("appends a GitHub PR when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], prItem);
|
||||
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
});
|
||||
|
||||
it("removes an existing GitHub item with the same kind+number", () => {
|
||||
const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not affect other items with different kind or number", () => {
|
||||
const start: UserComposerAttachment[] = [
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
];
|
||||
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
|
||||
const next = toggleGithubAttachment(start, otherIssue);
|
||||
expect(next).toEqual([
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
{ kind: "github_issue", item: otherIssue },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
|
||||
it("locates items via their composite kind:number id", () => {
|
||||
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
||||
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
|
||||
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes when an attachment list already contains a matching GitHub item", () => {
|
||||
const attachments: ComposerAttachment[] = [
|
||||
{ kind: "image", metadata: imageWithId("img-x") },
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
reviewWorkspaceAttachment("ignored"),
|
||||
];
|
||||
expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true);
|
||||
expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
|
||||
export interface QueuedComposerMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
}
|
||||
|
||||
export interface AttachmentPersister {
|
||||
persistFromBlob: (input: {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
}) => Promise<AttachmentMetadata>;
|
||||
persistFromFileUri: (input: {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
}) => Promise<AttachmentMetadata>;
|
||||
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface ComposerSendClient {
|
||||
sendAgentMessage: (
|
||||
agentId: string,
|
||||
text: string,
|
||||
options: {
|
||||
messageId: string;
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ComposerCancelClient {
|
||||
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface AgentStreamWriter {
|
||||
getHead: (agentId: string) => StreamItem[] | undefined;
|
||||
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
}
|
||||
|
||||
export interface QueueWriter {
|
||||
read: (agentId: string) => QueuedComposerMessage[];
|
||||
write: (
|
||||
updater: (prev: Map<string, QueuedComposerMessage[]>) => Map<string, QueuedComposerMessage[]>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export async function pickAndPersistImages(input: {
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
|
||||
}): Promise<AttachmentMetadata[]> {
|
||||
const result = await input.pickImages();
|
||||
if (!result?.length) return [];
|
||||
return await Promise.all(
|
||||
result.map(async (picked) => {
|
||||
const fileName = picked.fileName ?? null;
|
||||
const mimeType = picked.mimeType || "image/jpeg";
|
||||
if (picked.source.kind === "blob") {
|
||||
return await input.persister.persistFromBlob({
|
||||
blob: picked.source.blob,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
return await input.persister.persistFromFileUri({
|
||||
uri: picked.source.uri,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
|
||||
attachments: T[];
|
||||
index: number;
|
||||
deleteAttachments: AttachmentPersister["deleteAttachments"];
|
||||
}): T[] {
|
||||
const removed = input.attachments[input.index];
|
||||
if (removed?.kind === "image") {
|
||||
void input.deleteAttachments([removed.metadata]);
|
||||
}
|
||||
return input.attachments.filter((_, i) => i !== input.index);
|
||||
}
|
||||
|
||||
export interface CancelComposerAgentInput {
|
||||
client: ComposerCancelClient | null;
|
||||
agentId: string;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
|
||||
if (!input.isAgentRunning || input.isCancellingAgent) return false;
|
||||
if (!input.isConnected || !input.client) return false;
|
||||
void input.client.cancelAgent(input.agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface DispatchComposerAgentMessageInput {
|
||||
client: ComposerSendClient;
|
||||
agentId: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
encodeImages: (
|
||||
images: AttachmentMetadata[],
|
||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||
stream: AgentStreamWriter;
|
||||
}
|
||||
|
||||
export async function dispatchComposerAgentMessage(
|
||||
input: DispatchComposerAgentMessageInput,
|
||||
): Promise<void> {
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
|
||||
const messageId = generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: messageId,
|
||||
text: input.text,
|
||||
timestamp: new Date(),
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
agentId: string,
|
||||
userMessage: StreamItem,
|
||||
stream: AgentStreamWriter,
|
||||
): void {
|
||||
const head = stream.getHead(agentId);
|
||||
if (head && head.length > 0) {
|
||||
stream.setHead((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
stream.setTail((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageInput {
|
||||
agentId: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
queue: QueueWriter;
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageResult {
|
||||
queued: QueuedComposerMessage | null;
|
||||
}
|
||||
|
||||
export function queueComposerMessage(input: QueueComposerMessageInput): QueueComposerMessageResult {
|
||||
const trimmed = input.text.trim();
|
||||
if (!trimmed && input.attachments.length === 0) {
|
||||
return { queued: null };
|
||||
}
|
||||
const item: QueuedComposerMessage = {
|
||||
id: generateMessageId(),
|
||||
text: trimmed,
|
||||
attachments: input.attachments,
|
||||
};
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(input.agentId, [...(prev.get(input.agentId) ?? []), item]);
|
||||
return next;
|
||||
});
|
||||
return { queued: item };
|
||||
}
|
||||
|
||||
export interface EditQueuedComposerMessageInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
}
|
||||
|
||||
export interface EditQueuedComposerMessageResult {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
}
|
||||
|
||||
export function editQueuedComposerMessage(
|
||||
input: EditQueuedComposerMessageInput,
|
||||
): EditQueuedComposerMessageResult | null {
|
||||
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||
if (!item) return null;
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
input.agentId,
|
||||
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
text: item.text,
|
||||
attachments: userAttachmentsOnly(item.attachments),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendQueuedComposerMessageNowInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SendQueuedComposerMessageNowResult =
|
||||
| { status: "missing" }
|
||||
| { status: "submitted" }
|
||||
| { status: "failed"; errorMessage: string };
|
||||
|
||||
export async function sendQueuedComposerMessageNow(
|
||||
input: SendQueuedComposerMessageNowInput,
|
||||
): Promise<SendQueuedComposerMessageNowResult> {
|
||||
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||
if (!item) return { status: "missing" };
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
input.agentId,
|
||||
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await input.submitMessage({ text: item.text, attachments: item.attachments });
|
||||
return { status: "submitted" };
|
||||
} catch (error) {
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(input.agentId, [item, ...(prev.get(input.agentId) ?? [])]);
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Failed to send message",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenComposerAttachmentInput {
|
||||
attachment: ComposerAttachment;
|
||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void;
|
||||
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean;
|
||||
openExternalUrl: (url: string) => void;
|
||||
}
|
||||
|
||||
export function openComposerAttachment(input: OpenComposerAttachmentInput): void {
|
||||
if (input.attachment.kind === "image") {
|
||||
input.setLightboxMetadata(input.attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceAttachment(input.attachment)) {
|
||||
input.openWorkspaceAttachment({ attachment: input.attachment });
|
||||
return;
|
||||
}
|
||||
input.openExternalUrl(input.attachment.item.url);
|
||||
}
|
||||
|
||||
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||
}
|
||||
|
||||
export function toggleGithubAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (attachment: UserComposerAttachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number;
|
||||
if (current.some(matches)) {
|
||||
return current.filter((attachment) => !matches(attachment));
|
||||
}
|
||||
return [...current, buildGithubAttachment(item)];
|
||||
}
|
||||
|
||||
export function findGithubItemByOption(
|
||||
items: readonly GitHubSearchItem[],
|
||||
optionId: string,
|
||||
): GitHubSearchItem | undefined {
|
||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||
}
|
||||
|
||||
export function isAttachmentSelectedForGithubItem(
|
||||
current: readonly ComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): boolean {
|
||||
return userAttachmentsOnly(current).some(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||
|
||||
@@ -18,8 +21,8 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
||||
continue;
|
||||
}
|
||||
|
||||
if (composerWorkspaceAttachment.is(attachment)) {
|
||||
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
||||
if (workspaceAttachment) {
|
||||
reviewAttachments.push(workspaceAttachment);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@ import {
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
AgentStatusBar,
|
||||
DraftAgentStatusBar,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
} from "./agent-status-bar";
|
||||
import { ContextWindowMeter } from "./context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
MessageInput,
|
||||
@@ -44,6 +42,22 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
editQueuedComposerMessage,
|
||||
findGithubItemByOption,
|
||||
isAttachmentSelectedForGithubItem,
|
||||
openComposerAttachment,
|
||||
pickAndPersistImages,
|
||||
queueComposerMessage,
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
type AgentStreamWriter,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "@/components/composer-actions";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -79,17 +93,12 @@ import type {
|
||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
|
||||
interface QueuedMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
}
|
||||
type QueuedMessage = QueuedComposerMessage;
|
||||
|
||||
type AttachmentListUpdater =
|
||||
| UserComposerAttachment[]
|
||||
@@ -128,52 +137,18 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||
}
|
||||
|
||||
async function pickAndPersistImages(
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>,
|
||||
): Promise<ImageAttachment[]> {
|
||||
const result = await pickImages();
|
||||
if (!result?.length) return [];
|
||||
return await Promise.all(
|
||||
result.map(async (pickedImage) => {
|
||||
if (pickedImage.source.kind === "blob") {
|
||||
return await persistAttachmentFromBlob({
|
||||
blob: pickedImage.source.blob,
|
||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
});
|
||||
}
|
||||
return await persistAttachmentFromFileUri({
|
||||
uri: pickedImage.source.uri,
|
||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
|
||||
const removed = prev[index];
|
||||
if (removed?.kind === "image") {
|
||||
void deleteAttachments([removed.metadata]);
|
||||
}
|
||||
return prev.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||
const disabled =
|
||||
!isConnected || isCancellingAgent ? (styles.buttonDisabled as object) : undefined;
|
||||
return [styles.cancelButton as object, disabled].filter((value): value is object =>
|
||||
Boolean(value),
|
||||
);
|
||||
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||
}
|
||||
|
||||
function buildRealtimeVoiceButtonStyle(
|
||||
hovered: boolean | undefined,
|
||||
voiceButtonDisabled: boolean,
|
||||
): object[] {
|
||||
const hoveredStyle = hovered ? (styles.iconButtonHovered as object) : undefined;
|
||||
const disabledStyle = voiceButtonDisabled ? (styles.buttonDisabled as object) : undefined;
|
||||
return [styles.realtimeVoiceButton as object, hoveredStyle, disabledStyle].filter(
|
||||
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
|
||||
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
|
||||
return [styles.realtimeVoiceButton, hoveredStyle, disabledStyle].filter(
|
||||
(value): value is object => Boolean(value),
|
||||
);
|
||||
}
|
||||
@@ -240,45 +215,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
||||
}
|
||||
|
||||
function findGithubItemByOption(
|
||||
items: readonly GitHubSearchItem[],
|
||||
optionId: string,
|
||||
): GitHubSearchItem | undefined {
|
||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||
}
|
||||
|
||||
function isAttachmentSelectedForGithubItem(
|
||||
current: readonly ComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): boolean {
|
||||
return composerWorkspaceAttachment
|
||||
.userAttachmentsOnly(current)
|
||||
.some(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
}
|
||||
|
||||
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||
}
|
||||
|
||||
function toggleGithubAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (attachment: UserComposerAttachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number;
|
||||
if (current.some(matches)) {
|
||||
return current.filter((attachment) => !matches(attachment));
|
||||
}
|
||||
return [...current, buildGithubAttachment(item)];
|
||||
}
|
||||
|
||||
interface RenderAttachmentPreviewListArgs {
|
||||
selectedAttachments: ComposerAttachment[];
|
||||
isComposerLocked: boolean;
|
||||
@@ -419,104 +355,6 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
|
||||
});
|
||||
}
|
||||
|
||||
interface DispatchAgentMessageSendArgs {
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
serverId: string;
|
||||
targetAgentId: string;
|
||||
text: string;
|
||||
sendAttachments: ComposerAttachment[];
|
||||
setAgentStreamHead: ReturnType<typeof useSessionStore.getState>["setAgentStreamHead"];
|
||||
setAgentStreamTail: ReturnType<typeof useSessionStore.getState>["setAgentStreamTail"];
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
args: DispatchAgentMessageSendArgs & { userMessage: StreamItem },
|
||||
): void {
|
||||
const { serverId, targetAgentId, userMessage, setAgentStreamHead, setAgentStreamTail } = args;
|
||||
const currentHead = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.agentStreamHead?.get(targetAgentId);
|
||||
if (currentHead && currentHead.length > 0) {
|
||||
setAgentStreamHead(serverId, (prev) => {
|
||||
const head = prev.get(targetAgentId) || [];
|
||||
const updated = new Map(prev);
|
||||
updated.set(targetAgentId, [...head, userMessage]);
|
||||
return updated;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const currentStream = prev.get(targetAgentId) || [];
|
||||
const updated = new Map(prev);
|
||||
updated.set(targetAgentId, [...currentStream, userMessage]);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Promise<void> {
|
||||
const { client, targetAgentId, text, sendAttachments } = args;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(sendAttachments);
|
||||
const clientMessageId = generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: clientMessageId,
|
||||
text,
|
||||
timestamp: new Date(),
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
appendUserMessageToStream({ ...args, userMessage });
|
||||
const imagesData = await encodeImages(wirePayload.images);
|
||||
await client.sendAgentMessage(targetAgentId, text, {
|
||||
messageId: clientMessageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
function openComposerAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
|
||||
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
|
||||
): void {
|
||||
if (attachment.kind === "image") {
|
||||
setLightboxMetadata(attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (composerWorkspaceAttachment.is(attachment)) {
|
||||
openWorkspaceAttachment({ attachment });
|
||||
return;
|
||||
}
|
||||
void openExternalUrl(attachment.item.url);
|
||||
}
|
||||
|
||||
interface CancelRunningAgentArgs {
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
agentIdRef: { current: string };
|
||||
setIsCancellingAgent: (value: boolean) => void;
|
||||
messageInputRef: { current: MessageInputRef | null };
|
||||
}
|
||||
|
||||
function cancelRunningAgent(args: CancelRunningAgentArgs): void {
|
||||
const {
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
client,
|
||||
agentIdRef,
|
||||
setIsCancellingAgent,
|
||||
messageInputRef,
|
||||
} = args;
|
||||
if (!isAgentRunning || isCancellingAgent) return;
|
||||
if (!isConnected || !client) return;
|
||||
setIsCancellingAgent(true);
|
||||
void client.cancelAgent(agentIdRef.current);
|
||||
messageInputRef.current?.focus();
|
||||
}
|
||||
|
||||
function focusMessageInputWithPlatformStrategy(messageInputRef: {
|
||||
current: MessageInputRef | null;
|
||||
}): void {
|
||||
@@ -1155,14 +993,18 @@ export function Composer({
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
await dispatchAgentMessageSend({
|
||||
const stream: AgentStreamWriter = {
|
||||
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
|
||||
setHead: (updater) => setAgentStreamHead(serverId, updater),
|
||||
setTail: (updater) => setAgentStreamTail(serverId, updater),
|
||||
};
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
serverId,
|
||||
targetAgentId,
|
||||
agentId: targetAgentId,
|
||||
text,
|
||||
sendAttachments,
|
||||
setAgentStreamHead,
|
||||
setAgentStreamTail,
|
||||
attachments: sendAttachments,
|
||||
encodeImages,
|
||||
stream,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
@@ -1175,33 +1017,23 @@ export function Composer({
|
||||
const isAgentRunning = agentState.status === "running";
|
||||
const hasAgent = agentState.status !== null;
|
||||
|
||||
const updateQueue = useCallback(
|
||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, updater(prev.get(agentId) ?? []));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[agentId, serverId, setQueuedMessages],
|
||||
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[]) => {
|
||||
const trimmedMessage = queuedMessage.trim();
|
||||
if (!trimmedMessage && queuedAttachments.length === 0) return;
|
||||
|
||||
const newItem = {
|
||||
id: generateMessageId(),
|
||||
text: trimmedMessage,
|
||||
const result = queueComposerMessage({
|
||||
agentId,
|
||||
text: queuedMessage,
|
||||
attachments: queuedAttachments,
|
||||
};
|
||||
|
||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
||||
return next;
|
||||
queue: queueWriter,
|
||||
});
|
||||
if (!result.queued) return;
|
||||
|
||||
setUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
@@ -1211,9 +1043,8 @@ export function Composer({
|
||||
[
|
||||
agentId,
|
||||
clearSentAttachments,
|
||||
queueWriter,
|
||||
resetSuppression,
|
||||
serverId,
|
||||
setQueuedMessages,
|
||||
setSelectedAttachments,
|
||||
setUserInput,
|
||||
],
|
||||
@@ -1287,7 +1118,15 @@ export function Composer({
|
||||
);
|
||||
|
||||
const handlePickImage = useCallback(async () => {
|
||||
const newImages = await pickAndPersistImages(pickImages);
|
||||
const newImages = await pickAndPersistImages({
|
||||
pickImages,
|
||||
persister: {
|
||||
persistFromBlob: ({ blob, mimeType, fileName }) =>
|
||||
persistAttachmentFromBlob({ blob, mimeType, fileName }),
|
||||
persistFromFileUri: ({ uri, mimeType, fileName }) =>
|
||||
persistAttachmentFromFileUri({ uri, mimeType, fileName }),
|
||||
},
|
||||
});
|
||||
if (newImages.length === 0) return;
|
||||
addImages(newImages);
|
||||
}, [addImages, pickImages]);
|
||||
@@ -1301,14 +1140,23 @@ export function Composer({
|
||||
if (didRemoveWorkspaceAttachment) {
|
||||
return;
|
||||
}
|
||||
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
|
||||
setSelectedAttachments((prev) =>
|
||||
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
|
||||
);
|
||||
},
|
||||
[removeAttachment, selectedAttachments, setSelectedAttachments],
|
||||
);
|
||||
|
||||
const handleOpenAttachment = useCallback(
|
||||
(attachment: ComposerAttachment) => {
|
||||
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
|
||||
openComposerAttachment({
|
||||
attachment,
|
||||
setLightboxMetadata,
|
||||
openWorkspaceAttachment: openAttachment,
|
||||
openExternalUrl: (url) => {
|
||||
void openExternalUrl(url);
|
||||
},
|
||||
});
|
||||
},
|
||||
[openAttachment],
|
||||
);
|
||||
@@ -1320,15 +1168,16 @@ export function Composer({
|
||||
}, [isAgentRunning, isConnected]);
|
||||
|
||||
const handleCancelAgent = useCallback(() => {
|
||||
cancelRunningAgent({
|
||||
const didCancel = cancelComposerAgent({
|
||||
client,
|
||||
agentId: agentIdRef.current,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
client,
|
||||
agentIdRef,
|
||||
setIsCancellingAgent,
|
||||
messageInputRef,
|
||||
});
|
||||
if (!didCancel) return;
|
||||
setIsCancellingAgent(true);
|
||||
messageInputRef.current?.focus();
|
||||
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
||||
|
||||
const focusMessageInputForKeyboardAction = useCallback(() => {
|
||||
@@ -1394,33 +1243,34 @@ export function Composer({
|
||||
|
||||
const handleEditQueuedMessage = useCallback(
|
||||
(id: string) => {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
setUserInput(item.text);
|
||||
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
|
||||
const result = editQueuedComposerMessage({
|
||||
agentId,
|
||||
messageId: id,
|
||||
queue: queueWriter,
|
||||
});
|
||||
if (!result) return;
|
||||
setUserInput(result.text);
|
||||
setSelectedAttachments(result.attachments);
|
||||
},
|
||||
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
|
||||
[agentId, queueWriter, setSelectedAttachments, setUserInput],
|
||||
);
|
||||
|
||||
const handleSendQueuedNow = useCallback(
|
||||
async (id: string) => {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item) return;
|
||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
|
||||
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
||||
try {
|
||||
await submitMessage(item.text, item.attachments);
|
||||
} catch (error) {
|
||||
updateQueue((current) => [item, ...current]);
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId,
|
||||
messageId: id,
|
||||
queue: queueWriter,
|
||||
submitMessage: ({ text, attachments: queuedAttachments }) =>
|
||||
submitMessage(text, queuedAttachments),
|
||||
});
|
||||
if (result.status === "failed") {
|
||||
setSendError(result.errorMessage);
|
||||
}
|
||||
},
|
||||
[queuedMessages, submitMessage, updateQueue],
|
||||
[agentId, queueWriter, submitMessage],
|
||||
);
|
||||
|
||||
const handleQueue = useCallback(
|
||||
|
||||
@@ -132,7 +132,7 @@ function TreeRowItem({
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
void onCopyPath(entry.path);
|
||||
onCopyPath(entry.path);
|
||||
}, [onCopyPath, entry.path]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
|
||||
@@ -159,7 +159,9 @@ interface MarkdownWithStableRendererProps {
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
const markdownStyleMapping = (theme: Theme) => ({ style: createMarkdownStyles(theme) }) as never;
|
||||
const markdownStyleMapping = (theme: Theme): Partial<MarkdownWithStableRendererProps> => ({
|
||||
style: createMarkdownStyles(theme),
|
||||
});
|
||||
|
||||
const ThemedMicVocal = withUnistyles(MicVocal);
|
||||
const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
@@ -428,6 +430,8 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
||||
return `Issue #${attachment.number}`;
|
||||
case "text":
|
||||
return attachment.title ?? "Text attachment";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,7 +638,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
||||
useEffect(() => {
|
||||
if (cachedMetadata) {
|
||||
setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata));
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
setLoadState({ status: "loading" });
|
||||
@@ -924,11 +928,11 @@ function getInlineCodeAutoLinkUrl(
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = markdownParser.linkify.match(trimmed) as Array<{
|
||||
const matches: Array<{
|
||||
index: number;
|
||||
lastIndex: number;
|
||||
url: string;
|
||||
}> | null;
|
||||
}> | null = markdownParser.linkify.match(trimmed);
|
||||
if (!matches || matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -950,7 +954,7 @@ function nodeHasParentType(parent: unknown, type: string): boolean {
|
||||
typeof parent === "object" &&
|
||||
parent !== null &&
|
||||
"type" in parent &&
|
||||
(parent as { type?: string }).type === type
|
||||
(parent as Record<"type", unknown>)["type"] === type
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1638,16 +1642,13 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
>
|
||||
{Children.map(children, (child) =>
|
||||
isValidElement(child)
|
||||
? cloneElement(child, {
|
||||
style: [
|
||||
(child.props as { style?: StyleProp<TextStyle> }).style,
|
||||
{ color: styles.link.color as string | undefined },
|
||||
],
|
||||
} as Partial<{ style: StyleProp<TextStyle> }>)
|
||||
: child,
|
||||
)}
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
const childProps = child.props as { style?: StyleProp<TextStyle> };
|
||||
return cloneElement(child, {
|
||||
style: [childProps.style, { color: styles.link.color }],
|
||||
} as Partial<{ style: StyleProp<TextStyle> }>);
|
||||
})}
|
||||
</MarkdownLink>
|
||||
),
|
||||
image: (
|
||||
@@ -1740,8 +1741,8 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
headerLabel: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
text: {
|
||||
@@ -1769,7 +1770,7 @@ export const SpeakMessage = memo(function SpeakMessage({
|
||||
return (
|
||||
<View testID="speak-message" style={containerStyle}>
|
||||
<View style={speakMessageStylesheet.header}>
|
||||
<ThemedMicVocal size={14} uniProps={foregroundMutedColorMapping} />
|
||||
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||
</View>
|
||||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||
@@ -1810,9 +1811,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
successBg: {
|
||||
backgroundColor: "rgba(20, 83, 45, 0.3)",
|
||||
},
|
||||
errorBg: {
|
||||
backgroundColor: "rgba(127, 29, 29, 0.3)",
|
||||
},
|
||||
errorBg: {},
|
||||
artifactBg: {
|
||||
backgroundColor: "rgba(30, 58, 138, 0.4)",
|
||||
},
|
||||
@@ -1827,6 +1826,8 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
iconContainer: {
|
||||
flexShrink: 0,
|
||||
height: 20,
|
||||
justifyContent: "center",
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
@@ -1936,7 +1937,9 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
<IconComponent size={16} color={config.color} />
|
||||
</View>
|
||||
<View style={activityLogStylesheet.textContainer}>
|
||||
<Text style={messageTextStyle}>{displayMessage}</Text>
|
||||
<Text style={messageTextStyle} selectable>
|
||||
{displayMessage}
|
||||
</Text>
|
||||
{metadata && (
|
||||
<View style={activityLogStylesheet.detailsRow}>
|
||||
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
||||
@@ -2419,12 +2422,13 @@ function useDetailWheelPropagationBlocker(input: {
|
||||
const { detailWrapperRef, enabled } = input;
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
const node = detailWrapperRef.current as unknown as HTMLElement | null;
|
||||
if (!node || typeof node.addEventListener !== "function") {
|
||||
return;
|
||||
const rawRef: unknown = detailWrapperRef.current;
|
||||
if (!(rawRef instanceof HTMLElement)) {
|
||||
return () => {};
|
||||
}
|
||||
const node = rawRef;
|
||||
const stopWheelPropagation = (event: WheelEvent) => {
|
||||
if (shouldStopDetailWheelPropagation(node, event)) {
|
||||
event.stopPropagation();
|
||||
@@ -2594,7 +2598,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
enabled: !isNative && isExpanded && hasDetailContent,
|
||||
});
|
||||
|
||||
const shimmerLabelStyle = useMemo(
|
||||
const shimmerLabelStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() =>
|
||||
buildShimmerTextStyle({
|
||||
isWebShimmer,
|
||||
@@ -2603,7 +2607,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
webShimmerTrackStart,
|
||||
webShimmerTrackEnd,
|
||||
offsetX: labelOffsetX,
|
||||
}) as never,
|
||||
}),
|
||||
[
|
||||
isWebShimmer,
|
||||
webShimmerPeakWidth,
|
||||
@@ -2614,7 +2618,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
],
|
||||
);
|
||||
|
||||
const shimmerSecondaryStyle = useMemo(
|
||||
const shimmerSecondaryStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() =>
|
||||
buildShimmerTextStyle({
|
||||
isWebShimmer,
|
||||
@@ -2623,7 +2627,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
webShimmerTrackStart,
|
||||
webShimmerTrackEnd,
|
||||
offsetX: secondaryOffsetX,
|
||||
}) as never,
|
||||
}),
|
||||
[
|
||||
isWebShimmer,
|
||||
webShimmerPeakWidth,
|
||||
@@ -2805,9 +2809,9 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
|
||||
interface ToolCallProps {
|
||||
toolName: string;
|
||||
args?: unknown | null;
|
||||
result?: unknown | null;
|
||||
error?: unknown | null;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
@@ -2946,7 +2950,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
|
||||
useEffect(() => {
|
||||
if (!onInlineDetailsExpandedChange) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
return () => {
|
||||
onInlineDetailsExpandedChange(false);
|
||||
|
||||
@@ -29,7 +29,7 @@ interface PathRowProps {
|
||||
function PathRow({ path, active, onSelect }: PathRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const handlePress = useCallback(() => {
|
||||
void onSelect(path);
|
||||
onSelect(path);
|
||||
}, [onSelect, path]);
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -172,7 +172,7 @@ export function ProjectPickerModal() {
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (options.length > 0 && activeIndex < options.length) {
|
||||
void handleSelectPath(options[activeIndex]!);
|
||||
void handleSelectPath(options[activeIndex]);
|
||||
} else if (query.trim()) {
|
||||
handleSubmitCustom();
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ function CustomModelsSection(props: {
|
||||
},
|
||||
},
|
||||
});
|
||||
await refresh([provider as AgentProvider]);
|
||||
await refresh([provider]);
|
||||
},
|
||||
[patchConfig, provider, refresh],
|
||||
);
|
||||
@@ -284,7 +284,7 @@ export function ProviderDiagnosticSheet({
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||
const result = await client.getProviderDiagnostic(provider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
@@ -308,7 +308,7 @@ export function ProviderDiagnosticSheet({
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
|
||||
void Promise.all([refresh([provider]), fetchDiagnostic()]).catch((err) => {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
||||
});
|
||||
}, [fetchDiagnostic, provider, refresh]);
|
||||
|
||||
@@ -37,9 +37,9 @@ import {
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
getItem: vi.fn().mockResolvedValue(null),
|
||||
setItem: vi.fn().mockResolvedValue(undefined),
|
||||
removeItem: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -351,7 +351,7 @@ describe("sidebar workspace render isolation", () => {
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
...createWorkspaces()[1]!,
|
||||
...createWorkspaces()[1],
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -104,9 +104,14 @@ import {
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
@@ -123,6 +128,36 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
|
||||
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
|
||||
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreOptimisticallyHiddenWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
snapshot: WorkspaceDescriptor | null;
|
||||
}): void {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
if (input.snapshot) {
|
||||
useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
|
||||
}
|
||||
}
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -404,7 +439,7 @@ function WorkspaceStatusIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
let KindIcon: typeof ThemedMonitor | typeof ThemedFolderGit2 | null;
|
||||
let KindIcon: typeof ThemedMonitor | null;
|
||||
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
||||
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
|
||||
else KindIcon = null;
|
||||
@@ -1514,16 +1549,7 @@ function WorkspaceRowWithMenu({
|
||||
toast.error(message);
|
||||
});
|
||||
})();
|
||||
}, [
|
||||
archiveWorktree,
|
||||
isArchiving,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.workspaceDirectory,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
@@ -1549,6 +1575,7 @@ function WorkspaceRowWithMenu({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
@@ -1558,20 +1585,18 @@ function WorkspaceRowWithMenu({
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
@@ -1693,6 +1718,7 @@ function NonGitProjectRowWithMenuContent({
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
const snapshot = hideWorkspaceOptimistically(workspace);
|
||||
redirectAfterArchive();
|
||||
|
||||
void (async () => {
|
||||
@@ -1702,20 +1728,18 @@ function NonGitProjectRowWithMenuContent({
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
snapshot,
|
||||
});
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2120,13 +2144,28 @@ function ProjectBlock({
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
const snapshots = new Map(
|
||||
project.workspaces.map((workspace) => [
|
||||
workspace.workspaceId,
|
||||
hideWorkspaceOptimistically(workspace),
|
||||
]),
|
||||
);
|
||||
|
||||
const isRejected = (r: PromiseSettledResult<unknown>) => r.status === "rejected";
|
||||
void Promise.allSettled(
|
||||
project.workspaces.map(async (ws) => {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreOptimisticallyHiddenWorkspace({
|
||||
serverId,
|
||||
workspaceId: ws.workspaceId,
|
||||
snapshot: snapshots.get(ws.workspaceId) ?? null,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
|
||||
@@ -125,6 +125,26 @@ interface SplitPaneDropData {
|
||||
paneId: string;
|
||||
}
|
||||
|
||||
function isWorkspaceTabDragData(data: unknown): data is WorkspaceTabDragData {
|
||||
return typeof data === "object" && data !== null && Reflect.get(data, "kind") === "workspace-tab";
|
||||
}
|
||||
|
||||
function isSplitPaneDropData(data: unknown): data is SplitPaneDropData {
|
||||
return (
|
||||
typeof data === "object" && data !== null && Reflect.get(data, "kind") === "split-pane-drop"
|
||||
);
|
||||
}
|
||||
|
||||
function asWorkspaceTabDragData(data: unknown): WorkspaceTabDragData | undefined {
|
||||
return isWorkspaceTabDragData(data) ? data : undefined;
|
||||
}
|
||||
|
||||
function asDragOverData(data: unknown): WorkspaceTabDragData | SplitPaneDropData | undefined {
|
||||
if (isWorkspaceTabDragData(data)) return data;
|
||||
if (isSplitPaneDropData(data)) return data;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
|
||||
node: SplitNode;
|
||||
uiTabs: WorkspaceTab[];
|
||||
@@ -185,10 +205,10 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
[buildPaneContentModel, paneId, tabDescriptor],
|
||||
);
|
||||
|
||||
const wrapperStyle = useMemo(
|
||||
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
|
||||
[isVisible],
|
||||
);
|
||||
const wrapperStyle = useMemo(() => {
|
||||
const display: "flex" | "none" = isVisible ? "flex" : "none";
|
||||
return { display, flex: 1 };
|
||||
}, [isVisible]);
|
||||
const handleFocusPane = useCallback(() => {
|
||||
onFocusPane(paneId);
|
||||
}, [onFocusPane, paneId]);
|
||||
@@ -388,8 +408,8 @@ export function SplitContainer({
|
||||
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
if (data?.kind !== "workspace-tab") {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
if (!data) {
|
||||
setActiveDragTabId(null);
|
||||
setDropPreview(null);
|
||||
setTabDropPreview(null);
|
||||
@@ -406,11 +426,8 @@ export function SplitContainer({
|
||||
|
||||
const updateDropPreview = useCallback(
|
||||
(event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
if (activeData?.kind !== "workspace-tab") {
|
||||
setDropPreview(null);
|
||||
@@ -513,11 +530,8 @@ export function SplitContainer({
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = event.active.data.current as WorkspaceTabDragData | undefined;
|
||||
const overData = event.over?.data.current as
|
||||
| WorkspaceTabDragData
|
||||
| SplitPaneDropData
|
||||
| undefined;
|
||||
const activeData = asWorkspaceTabDragData(event.active.data.current);
|
||||
const overData = asDragOverData(event.over?.data.current);
|
||||
|
||||
setActiveDragTabId(null);
|
||||
|
||||
@@ -905,17 +919,14 @@ function SplitPaneView({
|
||||
|
||||
useEffect(() => {
|
||||
if (isNative) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const paneElement = paneRef.current as unknown as HTMLElement | null;
|
||||
if (
|
||||
!paneElement ||
|
||||
typeof paneElement.addEventListener !== "function" ||
|
||||
typeof paneElement.removeEventListener !== "function"
|
||||
) {
|
||||
return;
|
||||
const rawRef: unknown = paneRef.current;
|
||||
if (!(rawRef instanceof HTMLElement)) {
|
||||
return () => {};
|
||||
}
|
||||
const paneElement = rawRef;
|
||||
|
||||
const handlePanePointerDown = (event: PointerEvent) => {
|
||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -18,6 +19,7 @@ import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
@@ -127,6 +129,7 @@ interface TerminalEmulatorProps {
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
@@ -140,6 +143,16 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function isTerminalState(value: unknown): value is TerminalState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"rows" in value &&
|
||||
"cols" in value &&
|
||||
"grid" in value
|
||||
);
|
||||
}
|
||||
|
||||
function ensureTerminalScrollbarStyle(): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
@@ -181,6 +194,7 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onRendererReadyChange,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
resizeRequestToken = 0,
|
||||
@@ -198,6 +212,8 @@ export default function TerminalEmulator({
|
||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||
const xtermThemeRef = useRef(xtermTheme);
|
||||
xtermThemeRef.current = xtermTheme;
|
||||
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
|
||||
onRendererReadyChangeRef.current = onRendererReadyChange;
|
||||
const mountCallbacksRef = useRef({
|
||||
onInput,
|
||||
onResize,
|
||||
@@ -224,20 +240,41 @@ export default function TerminalEmulator({
|
||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||
const [isScrollActive, setIsScrollActive] = useState(false);
|
||||
|
||||
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
|
||||
useDOMImperativeHandle(
|
||||
ref as Ref<DOMImperativeFactory>,
|
||||
() =>
|
||||
({
|
||||
writeOutput: (text: string) => {
|
||||
runtimeRef.current?.write({ text });
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
domBridgeRef,
|
||||
(): DOMImperativeFactory => ({
|
||||
writeOutput: (...args) => {
|
||||
const text = args[0];
|
||||
if (typeof text === "string") runtimeRef.current?.write({ text });
|
||||
},
|
||||
renderSnapshot: (...args) => {
|
||||
const state = args[0];
|
||||
if (state === null) {
|
||||
runtimeRef.current?.renderSnapshot({ state: null });
|
||||
} else if (isTerminalState(state)) {
|
||||
runtimeRef.current?.renderSnapshot({ state });
|
||||
},
|
||||
clear: () => {
|
||||
runtimeRef.current?.clear();
|
||||
},
|
||||
}) as unknown as DOMImperativeFactory,
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
runtimeRef.current?.clear();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TerminalEmulatorHandle => ({
|
||||
writeOutput: (text: string) => {
|
||||
runtimeRef.current?.write({ text });
|
||||
},
|
||||
renderSnapshot: (state: TerminalState | null) => {
|
||||
runtimeRef.current?.renderSnapshot({ state });
|
||||
},
|
||||
clear: () => {
|
||||
runtimeRef.current?.clear();
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -254,7 +291,7 @@ export default function TerminalEmulator({
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root || !swipeGesturesEnabled) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const SWIPE_MIN_PX = 22;
|
||||
@@ -371,7 +408,7 @@ export default function TerminalEmulator({
|
||||
const host = hostRef.current;
|
||||
const root = rootRef.current;
|
||||
if (!host || !root) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
@@ -389,9 +426,11 @@ export default function TerminalEmulator({
|
||||
initialSnapshot: initialSnapshotRef.current,
|
||||
theme: mountedThemeRef.current,
|
||||
});
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
||||
|
||||
return () => {
|
||||
runtime.unmount();
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: false });
|
||||
if (runtimeRef.current === runtime) {
|
||||
runtimeRef.current = null;
|
||||
}
|
||||
@@ -416,7 +455,7 @@ export default function TerminalEmulator({
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
runtimeRef.current?.resize({ force: true });
|
||||
return focusWithRetries({
|
||||
@@ -444,14 +483,14 @@ export default function TerminalEmulator({
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||
if (!viewportElement) {
|
||||
viewportRef.current = null;
|
||||
setViewportMetrics({ offset: 0, viewportSize: 0, contentSize: 0 });
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
viewportRef.current = viewportElement;
|
||||
@@ -559,7 +598,7 @@ export default function TerminalEmulator({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingScrollbar) {
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
|
||||
@@ -28,6 +28,12 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
shouldReplayTerminalSnapshotForRenderer,
|
||||
shouldShowTerminalLoadingOverlay,
|
||||
type TerminalRendererReadyChange,
|
||||
} from "@/utils/terminal-renderer-readiness";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
@@ -164,6 +170,7 @@ export function TerminalPane({
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
|
||||
// Keep the latest measured size for whichever client currently owns the pane,
|
||||
// but only dedupe resizes that this specific client has already pushed.
|
||||
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
@@ -175,6 +182,7 @@ export function TerminalPane({
|
||||
);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
@@ -195,6 +203,20 @@ export function TerminalPane({
|
||||
const requestTerminalReflow = useCallback(() => {
|
||||
setResizeRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
const handleRendererReadyChange = useCallback(
|
||||
(change: TerminalRendererReadyChange) => {
|
||||
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
|
||||
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
if (snapshot) {
|
||||
emulatorRef.current?.renderSnapshot(snapshot);
|
||||
}
|
||||
},
|
||||
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||
@@ -617,6 +639,13 @@ export function TerminalPane({
|
||||
if (!swipeGesturesEnabled) return;
|
||||
onOpenFileExplorer();
|
||||
}, [swipeGesturesEnabled, onOpenFileExplorer]);
|
||||
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused,
|
||||
hasStreamError: Boolean(streamError),
|
||||
isAttaching,
|
||||
rendererReadyStreamKey,
|
||||
terminalStreamKey,
|
||||
});
|
||||
|
||||
if (!client || !isConnected) {
|
||||
return (
|
||||
@@ -634,11 +663,12 @@ export function TerminalPane({
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||
streamKey={`${scopeKey}:${terminalId}`}
|
||||
streamKey={terminalStreamKey}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
onSwipeRight={handleSwipeRight}
|
||||
onSwipeLeft={handleSwipeLeft}
|
||||
onInput={handleTerminalData}
|
||||
@@ -654,7 +684,7 @@ export function TerminalPane({
|
||||
<View style={styles.terminalGestureContainer} />
|
||||
)}
|
||||
|
||||
{isAttaching && isWorkspaceFocused ? (
|
||||
{showLoadingOverlay ? (
|
||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
|
||||
@@ -18,7 +18,7 @@ export type ToastVariant = "default" | "success" | "error";
|
||||
export interface ToastShowOptions {
|
||||
icon?: ReactNode;
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
durationMs?: number | null;
|
||||
nativeAndroid?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export interface ToastState {
|
||||
nativeMessage: string | null;
|
||||
icon?: ReactNode;
|
||||
variant: ToastVariant;
|
||||
durationMs: number;
|
||||
durationMs: number | null;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
@@ -59,11 +59,12 @@ export function useToastHost(): {
|
||||
}
|
||||
|
||||
const variant = options?.variant ?? "default";
|
||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||
const durationMs = options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs;
|
||||
const nativeAndroid = options?.nativeAndroid ?? false;
|
||||
|
||||
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
|
||||
const duration = durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
|
||||
const duration =
|
||||
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
|
||||
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
|
||||
return;
|
||||
}
|
||||
@@ -148,8 +149,13 @@ export function ToastViewport({
|
||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
||||
|
||||
const scheduleDismiss = useCallback(
|
||||
(durationMs: number) => {
|
||||
(durationMs: number | null) => {
|
||||
clearTimer();
|
||||
if (durationMs === null) {
|
||||
remainingDurationRef.current = 0;
|
||||
dismissDeadlineRef.current = null;
|
||||
return;
|
||||
}
|
||||
const nextDurationMs = Math.max(0, durationMs);
|
||||
remainingDurationRef.current = nextDurationMs;
|
||||
dismissDeadlineRef.current = Date.now() + nextDurationMs;
|
||||
@@ -169,7 +175,7 @@ export function ToastViewport({
|
||||
}, [clearTimer]);
|
||||
|
||||
const resumeDismiss = useCallback(() => {
|
||||
if (!toast) {
|
||||
if (!toast || toast.durationMs === null) {
|
||||
return;
|
||||
}
|
||||
scheduleDismiss(remainingDurationRef.current || toast.durationMs);
|
||||
@@ -204,7 +210,6 @@ export function ToastViewport({
|
||||
}),
|
||||
]).start();
|
||||
|
||||
remainingDurationRef.current = toast.durationMs;
|
||||
scheduleDismiss(toast.durationMs);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -228,8 +228,7 @@ export function ComboboxItem({
|
||||
const itemPressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.comboboxItem,
|
||||
Boolean(hovered) &&
|
||||
(elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
|
||||
pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
|
||||
active && styles.comboboxItemActive,
|
||||
disabled && styles.comboboxItemDisabled,
|
||||
@@ -488,7 +487,7 @@ function handleDesktopEnterKey(input: DesktopKeyHandlerInput) {
|
||||
if (input.orderedVisibleOptions.length === 0) return;
|
||||
const { activeIndex, orderedVisibleOptions } = input;
|
||||
const index = activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
||||
input.handleSelect(orderedVisibleOptions[index]!.id);
|
||||
input.handleSelect(orderedVisibleOptions[index].id);
|
||||
}
|
||||
|
||||
interface FloatingSizeSetters {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -89,7 +88,7 @@ function useControllableOpenState({
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
@@ -175,25 +174,23 @@ function computePosition({
|
||||
return { x, y, actualPlacement };
|
||||
}
|
||||
|
||||
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||
return typeof fn === "function";
|
||||
}
|
||||
|
||||
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
|
||||
const wrapper = event as
|
||||
| {
|
||||
nativeEvent?: { pageX?: number; pageY?: number; clientX?: number; clientY?: number };
|
||||
pageX?: number;
|
||||
pageY?: number;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const nativeEvent = wrapper?.nativeEvent ?? wrapper;
|
||||
const pageX = nativeEvent?.pageX;
|
||||
const pageY = nativeEvent?.pageY;
|
||||
if (typeof event !== "object" || event === null) {
|
||||
return null;
|
||||
}
|
||||
const nativeEvent = Reflect.get(event, "nativeEvent");
|
||||
const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
|
||||
const pageX = Reflect.get(native, "pageX");
|
||||
const pageY = Reflect.get(native, "pageY");
|
||||
if (typeof pageX === "number" && typeof pageY === "number") {
|
||||
return { pageX, pageY };
|
||||
}
|
||||
const clientX = nativeEvent?.clientX;
|
||||
const clientY = nativeEvent?.clientY;
|
||||
const clientX = Reflect.get(native, "clientX");
|
||||
const clientY = Reflect.get(native, "clientY");
|
||||
if (typeof clientX === "number" && typeof clientY === "number") {
|
||||
return { pageX: clientX, pageY: clientY };
|
||||
}
|
||||
@@ -206,7 +203,7 @@ function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
(ref as MutableRefObject<T>).current = value;
|
||||
Object.assign(ref, { current: value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,10 +323,13 @@ export function ContextMenuTrigger({
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
const e = event as { preventDefault?: () => void; stopPropagation?: () => void } | undefined;
|
||||
e?.preventDefault?.();
|
||||
e?.stopPropagation?.();
|
||||
openAtEvent(event as GestureResponderEvent);
|
||||
if (typeof event === "object" && event !== null) {
|
||||
const preventDefault = Reflect.get(event, "preventDefault");
|
||||
const stopPropagation = Reflect.get(event, "stopPropagation");
|
||||
if (isCallable(preventDefault)) preventDefault.call(event);
|
||||
if (isCallable(stopPropagation)) stopPropagation.call(event);
|
||||
}
|
||||
openAtEvent(event);
|
||||
},
|
||||
[openAtEvent],
|
||||
);
|
||||
@@ -337,7 +337,7 @@ export function ContextMenuTrigger({
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (typeof style === "function") {
|
||||
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
|
||||
return style({ pressed, hovered, open: ctx.open });
|
||||
}
|
||||
return style;
|
||||
},
|
||||
@@ -434,40 +434,29 @@ export function ContextMenuContent({
|
||||
|
||||
// Measure trigger when opening (fallback) and capture point anchors.
|
||||
useEffect(() => {
|
||||
if (useMobileSheet) {
|
||||
if (useMobileSheet || !open) {
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (anchorRect) {
|
||||
setTriggerRect(anchorRect);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (!triggerRef.current) {
|
||||
setTriggerRect(null);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
measureElement(triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect({
|
||||
...rect,
|
||||
y: rect.y + statusBarHeight,
|
||||
});
|
||||
return;
|
||||
void measureElement(triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -62,6 +62,11 @@ interface DropdownMenuContextValue {
|
||||
|
||||
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
|
||||
|
||||
export function useDropdownMenuClose(): () => void {
|
||||
const { setOpen } = useDropdownMenuContext("useDropdownMenuClose");
|
||||
return useCallback(() => setOpen(false), [setOpen]);
|
||||
}
|
||||
|
||||
function useDropdownMenuContext(componentName: string): DropdownMenuContextValue {
|
||||
const ctx = useContext(DropdownMenuContext);
|
||||
if (!ctx) {
|
||||
@@ -81,7 +86,7 @@ function useControllableOpenState({
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
@@ -743,12 +748,12 @@ export function DropdownMenuItem({
|
||||
return [
|
||||
styles.item,
|
||||
selectedStyle,
|
||||
selected && (Boolean(hovered) || pressed) && selectedVariant !== "accent"
|
||||
selected && (hovered || pressed) && selectedVariant !== "accent"
|
||||
? styles.itemSelectedInteractive
|
||||
: null,
|
||||
isDisabled ? styles.itemDisabled : null,
|
||||
muted && !isDisabled ? styles.itemMuted : null,
|
||||
Boolean(hovered) && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
pressed && !isDisabled ? styles.itemPressed : null,
|
||||
];
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
@@ -82,24 +81,20 @@ function shouldOpenOnFocus(): boolean {
|
||||
return !isWeb || lastInputWasKeyboard;
|
||||
}
|
||||
|
||||
function composeEventHandlers<E>(
|
||||
original?: (event: E) => void,
|
||||
injected?: (event: E) => void,
|
||||
): (event: E) => void {
|
||||
return (event: E) => {
|
||||
original?.(event);
|
||||
injected?.(event);
|
||||
};
|
||||
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||
return typeof fn === "function";
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
(ref as { current: T }).current = value;
|
||||
}
|
||||
function composeEventHandlers(
|
||||
original: unknown,
|
||||
injected: (event: unknown) => void,
|
||||
): (event: unknown) => void {
|
||||
return (event: unknown) => {
|
||||
if (isCallable(original)) {
|
||||
original(event);
|
||||
}
|
||||
injected(event);
|
||||
};
|
||||
}
|
||||
|
||||
function useControllableOpenState({
|
||||
@@ -113,7 +108,7 @@ function useControllableOpenState({
|
||||
}): [boolean, (next: boolean) => void] {
|
||||
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||
const isControlled = typeof open === "boolean";
|
||||
const value = isControlled ? Boolean(open) : internalOpen;
|
||||
const value = isControlled ? open : internalOpen;
|
||||
const setValue = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
@@ -320,7 +315,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleHoverIn = useCallback(
|
||||
(e?: unknown) => {
|
||||
onHoverIn?.(e as never);
|
||||
if (isCallable(onHoverIn)) onHoverIn(e);
|
||||
scheduleOpen();
|
||||
},
|
||||
[onHoverIn, scheduleOpen],
|
||||
@@ -328,7 +323,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleHoverOut = useCallback(
|
||||
(e?: unknown) => {
|
||||
onHoverOut?.(e as never);
|
||||
if (isCallable(onHoverOut)) onHoverOut(e);
|
||||
close();
|
||||
},
|
||||
[onHoverOut, close],
|
||||
@@ -336,7 +331,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: unknown) => {
|
||||
onFocus?.(e as never);
|
||||
if (isCallable(onFocus)) onFocus(e);
|
||||
if (!ctx.enabled || disabled) return;
|
||||
if (!shouldOpenOnFocus()) return;
|
||||
clearOpenTimer();
|
||||
@@ -347,7 +342,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: unknown) => {
|
||||
onBlur?.(e as never);
|
||||
if (isCallable(onBlur)) onBlur(e);
|
||||
close();
|
||||
},
|
||||
[close, onBlur],
|
||||
@@ -355,7 +350,7 @@ export function TooltipTrigger({
|
||||
|
||||
const handlePress = useCallback(
|
||||
(e: unknown) => {
|
||||
onPress?.(e as never);
|
||||
if (isCallable(onPress)) onPress(e);
|
||||
if (!ctx.enabled || disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -394,26 +389,33 @@ export function TooltipTrigger({
|
||||
throw new Error("TooltipTrigger with asChild expects a single React element child");
|
||||
}
|
||||
|
||||
const childProps = child.props as Record<string, unknown>;
|
||||
const mergedProps = {
|
||||
...childProps,
|
||||
const rawProps: unknown = child.props;
|
||||
if (typeof rawProps !== "object" || rawProps === null) {
|
||||
throw new Error("TooltipTrigger asChild child must have props object");
|
||||
}
|
||||
const mergedProps: Record<string, unknown> = {
|
||||
...Object.assign({}, rawProps),
|
||||
...triggerProps,
|
||||
disabled: childProps.disabled || disabled,
|
||||
onHoverIn: composeEventHandlers(childProps.onHoverIn as never, handleHoverIn),
|
||||
onHoverOut: composeEventHandlers(childProps.onHoverOut as never, handleHoverOut),
|
||||
onFocus: composeEventHandlers(childProps.onFocus as never, handleFocus),
|
||||
onBlur: composeEventHandlers(childProps.onBlur as never, handleBlur),
|
||||
onPress: composeEventHandlers(childProps.onPress as never, handlePress),
|
||||
onPointerEnter: composeEventHandlers(childProps.onPointerEnter as never, handleHoverIn),
|
||||
onPointerLeave: composeEventHandlers(childProps.onPointerLeave as never, handleHoverOut),
|
||||
onMouseEnter: composeEventHandlers(childProps.onMouseEnter as never, handleHoverIn),
|
||||
onMouseLeave: composeEventHandlers(childProps.onMouseLeave as never, handleHoverOut),
|
||||
} as Record<string, unknown>;
|
||||
disabled: Reflect.get(rawProps, "disabled") || disabled,
|
||||
onHoverIn: composeEventHandlers(Reflect.get(rawProps, "onHoverIn"), handleHoverIn),
|
||||
onHoverOut: composeEventHandlers(Reflect.get(rawProps, "onHoverOut"), handleHoverOut),
|
||||
onFocus: composeEventHandlers(Reflect.get(rawProps, "onFocus"), handleFocus),
|
||||
onBlur: composeEventHandlers(Reflect.get(rawProps, "onBlur"), handleBlur),
|
||||
onPress: composeEventHandlers(Reflect.get(rawProps, "onPress"), handlePress),
|
||||
onPointerEnter: composeEventHandlers(Reflect.get(rawProps, "onPointerEnter"), handleHoverIn),
|
||||
onPointerLeave: composeEventHandlers(Reflect.get(rawProps, "onPointerLeave"), handleHoverOut),
|
||||
onMouseEnter: composeEventHandlers(Reflect.get(rawProps, "onMouseEnter"), handleHoverIn),
|
||||
onMouseLeave: composeEventHandlers(Reflect.get(rawProps, "onMouseLeave"), handleHoverOut),
|
||||
};
|
||||
|
||||
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
|
||||
mergedProps[triggerRefProp] = (node: View | null) => {
|
||||
assignRef(existingRefProp, node);
|
||||
assignRef(ctx.triggerRef, node);
|
||||
const existingRefProp = Reflect.get(rawProps, triggerRefProp);
|
||||
mergedProps[triggerRefProp] = (node: View) => {
|
||||
if (isCallable(existingRefProp)) {
|
||||
existingRefProp(node);
|
||||
} else if (existingRefProp && typeof existingRefProp === "object") {
|
||||
Object.assign(existingRefProp, { current: node });
|
||||
}
|
||||
Object.assign(ctx.triggerRef, { current: node });
|
||||
};
|
||||
|
||||
return cloneElement(child, mergedProps);
|
||||
@@ -453,16 +455,15 @@ export function TooltipContent({
|
||||
setTriggerRect(null);
|
||||
setContentSize(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||
let cancelled = false;
|
||||
|
||||
measureElement(ctx.triggerRef.current).then((rect) => {
|
||||
if (cancelled) return;
|
||||
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return;
|
||||
void measureElement(ctx.triggerRef.current).then((rect) => {
|
||||
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -140,9 +140,9 @@ export function useWebScrollViewScrollbar(
|
||||
const scrollable = scrollableRef.current;
|
||||
if (!scrollable) return;
|
||||
if ("scrollToOffset" in scrollable) {
|
||||
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
|
||||
scrollable.scrollToOffset({ offset, animated: false });
|
||||
} else {
|
||||
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
|
||||
scrollable.scrollTo({ y: offset, animated: false });
|
||||
}
|
||||
},
|
||||
[scrollableRef],
|
||||
|
||||
@@ -248,8 +248,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
},
|
||||
];
|
||||
|
||||
const isConnectingToSavedHosts = hosts.length > 0 && !anyOnlineServerId;
|
||||
|
||||
const scrollContentContainerStyle = useMemo(
|
||||
() => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }],
|
||||
[theme.spacing, insets.bottom],
|
||||
@@ -266,20 +264,14 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<View style={styles.content}>
|
||||
<PaseoLogo size={96} />
|
||||
<View style={styles.copyBlock}>
|
||||
{isConnectingToSavedHosts ? (
|
||||
<Text style={styles.subtitle}>Connecting…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||
{isNative ? (
|
||||
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
|
||||
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
||||
<ExternalLink size={14} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Text style={styles.title}>Welcome to Paseo</Text>
|
||||
<Text style={styles.subtitle}>Connect your computer to get started</Text>
|
||||
{isNative ? (
|
||||
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
|
||||
<Text style={styles.setupLinkText}>paseo.sh</Text>
|
||||
<ExternalLink size={14} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
|
||||
@@ -14,11 +14,7 @@ import {
|
||||
type TimelineReducerSideEffect,
|
||||
} from "@/timeline/session-stream-reducers";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import type {
|
||||
AgentAttachment,
|
||||
AgentStreamEventPayload,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/shared/messages";
|
||||
import type { AgentAttachment, SessionOutboundMessage } from "@server/shared/messages";
|
||||
import { parseServerInfoStatusPayload } from "@server/shared/messages";
|
||||
import {
|
||||
buildAgentAttentionNotificationPayload,
|
||||
@@ -42,7 +38,6 @@ import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { isLocalWorktreeArchivePending } from "@/stores/checkout-git-actions-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
@@ -60,7 +55,10 @@ import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -543,6 +541,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
workspaces.set(workspace.id, workspace);
|
||||
}
|
||||
|
||||
@@ -1196,7 +1197,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event as AgentStreamEventPayload;
|
||||
const streamEvent = event;
|
||||
if (
|
||||
event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
@@ -1239,18 +1240,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
clearWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: String(message.payload.id),
|
||||
});
|
||||
removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) });
|
||||
removeWorkspace(serverId, String(message.payload.id));
|
||||
return;
|
||||
}
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
if (
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
serverId,
|
||||
workspace,
|
||||
isArchivePending: isLocalWorktreeArchivePending,
|
||||
})
|
||||
) {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { shouldSuppressWorkspaceUpsertForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
clearWorkspaceArchivePending,
|
||||
isWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
|
||||
const baseWorkspace: WorkspaceDescriptor = {
|
||||
id: "/repo/worktree",
|
||||
@@ -21,40 +26,83 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
||||
return { ...baseWorkspace, ...input };
|
||||
}
|
||||
|
||||
describe("shouldSuppressWorkspaceUpsertForLocalArchive", () => {
|
||||
it("suppresses archiving upserts for a locally pending archive", () => {
|
||||
const isArchivePending = vi.fn(() => true);
|
||||
describe("workspace archive pending suppression", () => {
|
||||
it("tracks a locally pending workspace archive by id and directory", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
|
||||
isArchivePending,
|
||||
workspaceId: "/repo/worktree",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isArchivePending).toHaveBeenCalledWith({
|
||||
serverId: "server-1",
|
||||
cwd: "/repo/worktree",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows archiving upserts when this client did not start the archive", () => {
|
||||
expect(
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspace: workspace(),
|
||||
isArchivePending: () => false,
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows normal upserts while a local archive is pending", () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSuppressWorkspaceUpsertForLocalArchive({
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ archivingAt: null }),
|
||||
isArchivePending: () => true,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses upserts for a locally pending archive", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
});
|
||||
|
||||
it("allows upserts when this client did not start the archive", () => {
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses stale normal upserts while a local archive is pending", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ archivingAt: null }),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,96 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export function shouldSuppressWorkspaceUpsertForLocalArchive(input: {
|
||||
interface PendingWorkspaceArchive {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}
|
||||
|
||||
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
|
||||
|
||||
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
|
||||
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
|
||||
}
|
||||
|
||||
export function markWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
|
||||
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
|
||||
workspaceId,
|
||||
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
|
||||
});
|
||||
pendingWorkspaceArchivesByServer.set(serverId, archives);
|
||||
}
|
||||
|
||||
export function clearWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId);
|
||||
if (!archives) {
|
||||
return;
|
||||
}
|
||||
archives.delete(pendingArchiveKey({ serverId, workspaceId }));
|
||||
if (archives.size === 0) {
|
||||
pendingWorkspaceArchivesByServer.delete(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceDirectory?: string | null;
|
||||
}): boolean {
|
||||
const serverId = input.serverId.trim();
|
||||
if (!serverId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId);
|
||||
if (!archives) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
||||
if (!workspaceDirectory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const archive of archives.values()) {
|
||||
if (archive.workspaceDirectory === workspaceDirectory) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
isArchivePending: (params: { serverId: string; cwd: string }) => boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
input.workspace.archivingAt !== null &&
|
||||
input.isArchivePending({
|
||||
serverId: input.serverId,
|
||||
cwd: input.workspace.workspaceDirectory,
|
||||
})
|
||||
);
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@ export function useVoice() {
|
||||
export function useVoiceOptional(): VoiceContextValue | null {
|
||||
const runtime = useContext(VoiceRuntimeContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
runtime ? runtime.subscribe : noopSubscribe,
|
||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
||||
runtime ? runtime.getSnapshot : getEmptySnapshot,
|
||||
runtime ? runtime.subscribe.bind(runtime) : noopSubscribe,
|
||||
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
|
||||
);
|
||||
|
||||
if (!runtime) {
|
||||
@@ -68,10 +68,10 @@ export function useVoiceOptional(): VoiceContextValue | null {
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
startVoice: runtime.startVoice,
|
||||
stopVoice: runtime.stopVoice,
|
||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
|
||||
toggleMute: runtime.toggleMute,
|
||||
startVoice: runtime.startVoice.bind(runtime),
|
||||
stopVoice: runtime.stopVoice.bind(runtime),
|
||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent.bind(runtime),
|
||||
toggleMute: runtime.toggleMute.bind(runtime),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,9 +86,9 @@ export function useVoiceTelemetry() {
|
||||
export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | null {
|
||||
const runtime = useContext(VoiceRuntimeContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
runtime ? runtime.subscribeTelemetry : noopSubscribe,
|
||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
|
||||
runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry,
|
||||
runtime ? runtime.subscribeTelemetry.bind(runtime) : noopSubscribe,
|
||||
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
|
||||
runtime ? runtime.getTelemetrySnapshot.bind(runtime) : getEmptyTelemetry,
|
||||
);
|
||||
|
||||
return runtime ? snapshot : null;
|
||||
@@ -140,7 +140,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
runtimeRef.current = runtime;
|
||||
}
|
||||
|
||||
const engine = engineRef.current!;
|
||||
const engine = engineRef.current;
|
||||
const runtime = runtimeRef.current!;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -208,12 +208,14 @@ vi.mock("@/desktop/updates/desktop-updates", () => ({
|
||||
|
||||
const daemonCommandMocks = vi.hoisted(() => ({
|
||||
getCliDaemonStatusMock: vi.fn(),
|
||||
startDesktopDaemonMock: vi.fn(),
|
||||
stopDesktopDaemonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
||||
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
|
||||
shouldUseDesktopDaemon: vi.fn(() => true),
|
||||
startDesktopDaemon: daemonCommandMocks.startDesktopDaemonMock,
|
||||
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
|
||||
}));
|
||||
|
||||
@@ -234,8 +236,15 @@ describe("LocalDaemonSection", () => {
|
||||
settingsState.updateSettings.mockReset();
|
||||
settingsState.updateSettings.mockResolvedValue();
|
||||
daemonStatusState.data.status.status = "running";
|
||||
daemonStatusState.data.status.desktopManaged = true;
|
||||
daemonStatusState.setStatus.mockReset();
|
||||
daemonStatusState.refetch.mockReset();
|
||||
daemonCommandMocks.startDesktopDaemonMock.mockReset();
|
||||
daemonCommandMocks.startDesktopDaemonMock.mockResolvedValue({
|
||||
...daemonStatusState.data.status,
|
||||
status: "running",
|
||||
desktopManaged: true,
|
||||
});
|
||||
daemonCommandMocks.stopDesktopDaemonMock.mockReset();
|
||||
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
|
||||
...daemonStatusState.data.status,
|
||||
@@ -297,4 +306,57 @@ describe("LocalDaemonSection", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("persists paused management before stopping the desktop-managed daemon", async () => {
|
||||
confirmDialogMock.mockResolvedValue(true);
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(settingsState.updateSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
daemonCommandMocks.stopDesktopDaemonMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not stop a manually managed daemon when pausing built-in daemon management", async () => {
|
||||
confirmDialogMock.mockResolvedValue(true);
|
||||
daemonStatusState.data.status.desktopManaged = false;
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
||||
daemon: {
|
||||
manageBuiltInDaemon: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the built-in daemon when management is re-enabled", async () => {
|
||||
settingsState.settings.daemon.manageBuiltInDaemon = false;
|
||||
const screen = render(<LocalDaemonSection />);
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(daemonCommandMocks.startDesktopDaemonMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(settingsState.updateSettings).toHaveBeenCalledWith({
|
||||
daemon: {
|
||||
manageBuiltInDaemon: true,
|
||||
},
|
||||
});
|
||||
expect(daemonStatusState.setStatus).toHaveBeenCalledWith({
|
||||
...daemonStatusState.data.status,
|
||||
status: "running",
|
||||
desktopManaged: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getCliDaemonStatus,
|
||||
shouldUseDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
@@ -41,6 +42,12 @@ function useDaemonManagementToggle(args: {
|
||||
if (!settings.manageBuiltInDaemon) {
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
void updateSettings({ manageBuiltInDaemon: true })
|
||||
.then(() => startDesktopDaemon())
|
||||
.then((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
refetch();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to update built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to update built-in daemon management.");
|
||||
@@ -66,25 +73,29 @@ function useDaemonManagementToggle(args: {
|
||||
|
||||
setIsUpdatingDaemonManagement(true);
|
||||
|
||||
const stopPromise =
|
||||
daemonStatus?.status === "running"
|
||||
? stopDesktopDaemon()
|
||||
: Promise.resolve(daemonStatus ?? null);
|
||||
|
||||
void stopPromise
|
||||
void updateSettings({ manageBuiltInDaemon: false })
|
||||
.then(() => {
|
||||
if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
|
||||
return stopDesktopDaemon();
|
||||
}
|
||||
return daemonStatus ?? null;
|
||||
})
|
||||
.then((newStatus) => {
|
||||
if (newStatus) {
|
||||
setStatus(newStatus);
|
||||
}
|
||||
return updateSettings({ manageBuiltInDaemon: false });
|
||||
return;
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to pause built-in daemon management", error);
|
||||
Alert.alert("Error", "Unable to pause built-in daemon management.");
|
||||
console.error("[Settings] Failed to stop built-in daemon", error);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Built-in daemon management was paused, but Paseo could not stop the daemon.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingDaemonManagement(false);
|
||||
|
||||
@@ -13,7 +13,7 @@ function encodeBinaryToBase64(data: Uint8Array | ArrayBuffer): string {
|
||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]!);
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return globalThis.btoa(binary);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,22 @@ export interface DesktopDialogOpenOptions {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DesktopDialogAskWithCheckboxOptions extends DesktopDialogAskOptions {
|
||||
checkboxLabel: string;
|
||||
checkboxChecked?: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopDialogAskWithCheckboxResult {
|
||||
confirmed: boolean;
|
||||
dontAskAgain: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopDialogBridge {
|
||||
ask?: (message: string, options?: DesktopDialogAskOptions) => Promise<boolean>;
|
||||
askWithCheckbox?: (
|
||||
message: string,
|
||||
options: DesktopDialogAskWithCheckboxOptions,
|
||||
) => Promise<DesktopDialogAskWithCheckboxResult>;
|
||||
open?: (options?: DesktopDialogOpenOptions) => Promise<string | string[] | null>;
|
||||
}
|
||||
|
||||
@@ -76,6 +90,7 @@ export interface DesktopBrowserShortcutEvent {
|
||||
|
||||
export interface DesktopBrowserBridge {
|
||||
setActivePane?: (browserId: string | null) => Promise<void>;
|
||||
clearPartition?: (browserId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopInvokeBridge {
|
||||
|
||||
@@ -110,7 +110,7 @@ export class DictationStreamSender {
|
||||
let sent = 0;
|
||||
while (this.sendSeq < this.segments.length) {
|
||||
const seq = this.sendSeq;
|
||||
const audio = this.segments[seq]!;
|
||||
const audio = this.segments[seq];
|
||||
client.sendDictationStreamChunk(dictationId, seq, audio, this.format);
|
||||
this.sendSeq = seq + 1;
|
||||
sent += 1;
|
||||
|
||||
@@ -35,7 +35,7 @@ const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
|
||||
function makeProviderMap(
|
||||
...definitions: AgentProviderDefinition[]
|
||||
): Map<AgentProvider, AgentProviderDefinition> {
|
||||
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
|
||||
return new Map(definitions.map((d) => [d.id, d]));
|
||||
}
|
||||
|
||||
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
|
||||
|
||||
@@ -196,8 +196,8 @@ function resolveProvider(input: {
|
||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||
return initialValues.provider;
|
||||
}
|
||||
if (preferences?.provider && allowedProviderMap.has(preferences.provider as AgentProvider)) {
|
||||
return preferences.provider as AgentProvider;
|
||||
if (preferences?.provider && allowedProviderMap.has(preferences.provider)) {
|
||||
return preferences.provider;
|
||||
}
|
||||
if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) {
|
||||
return null;
|
||||
@@ -396,7 +396,7 @@ function buildProviderDefinitionMapForStatuses(args: {
|
||||
|
||||
const matchingProviders = new Set(
|
||||
args.snapshotEntries
|
||||
.filter((entry) => args.statuses.has(entry.status) && entry.enabled !== false)
|
||||
.filter((entry) => args.statuses.has(entry.status) && entry.enabled)
|
||||
.map((entry) => entry.provider),
|
||||
);
|
||||
|
||||
|
||||
@@ -72,6 +72,34 @@ describe("useAgentInitialization", () => {
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
|
||||
});
|
||||
|
||||
it("times out initialization after 30 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = makeClient();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentInitialization({ serverId, client: client as never }),
|
||||
);
|
||||
|
||||
const promise = result.current.ensureAgentIsInitialized(agentId);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(29_999);
|
||||
});
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow("History sync timed out after 30s");
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined();
|
||||
expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get(agentId)).toBe(
|
||||
false,
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("refresh fetches a bounded canonical tail after refreshing the agent", async () => {
|
||||
const client = makeClient();
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/utils/agent-initialization";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
const INIT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function useAgentInitialization({
|
||||
serverId,
|
||||
|
||||
@@ -72,33 +72,15 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert audio file URI to Blob for daemon transport
|
||||
* Returns a Blob-like object that works in React Native
|
||||
*/
|
||||
async function uriToBlob(uri: string): Promise<Blob> {
|
||||
// Use expo-file-system to read the file
|
||||
const file = new File(uri);
|
||||
const base64 = await file.base64();
|
||||
const size = file.size;
|
||||
|
||||
// React Native doesn't support creating Blobs from binary data
|
||||
// Create a Blob-like object that has the methods we need
|
||||
const blobLike = {
|
||||
type: "audio/m4a",
|
||||
size: size,
|
||||
arrayBuffer: async () => {
|
||||
// Convert base64 to ArrayBuffer
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
},
|
||||
} as Blob;
|
||||
|
||||
return blobLike;
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: "audio/m4a" });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,19 +145,17 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
// Monitor audio levels if metering is enabled
|
||||
// Use configRef to access the latest callback without recreating the effect
|
||||
useEffect(() => {
|
||||
const currentConfig = configRef.current;
|
||||
if (!currentConfig?.onAudioLevel || !recorderState.isRecording) return;
|
||||
if (!configRef.current?.onAudioLevel || !recorderState.isRecording) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const metering = recorderState.metering;
|
||||
if (metering !== undefined && metering !== null) {
|
||||
// Normalize metering value (typically ranges from -160 to 0 dB)
|
||||
// Convert to 0-1 range where 0 is silence and 1 is loud
|
||||
// We'll use -40 dB as the threshold for "loud"
|
||||
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
|
||||
configRef.current?.onAudioLevel?.(normalized);
|
||||
}
|
||||
}, 100); // Check every 100ms
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [recorderState.metering, recorderState.isRecording]);
|
||||
@@ -217,20 +197,18 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
await recorder.prepareToRecordAsync();
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
|
||||
await recorder.record();
|
||||
recorder.record();
|
||||
attemptGuardRef.current.assertCurrent(attemptId);
|
||||
} catch (error) {
|
||||
setRecordingStartTime(null);
|
||||
if (error instanceof AttemptCancelledError) {
|
||||
return;
|
||||
}
|
||||
if ((error as { message?: string })?.message !== "Recording cancelled") {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message !== "Recording cancelled") {
|
||||
console.error("[AudioRecorder] Failed to start recording:", error);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to start audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
throw new Error(`Failed to start audio recording: ${message}`, { cause: error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -296,10 +274,8 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
} catch (error) {
|
||||
setRecordingStartTime(null);
|
||||
console.error("[AudioRecorder] Failed to stop recording:", error);
|
||||
throw new Error(
|
||||
`Failed to stop audio recording: ${(error as { message?: string })?.message ?? String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to stop audio recording: ${message}`, { cause: error });
|
||||
} finally {
|
||||
startStopMutexRef.current = null;
|
||||
}
|
||||
@@ -312,7 +288,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
}, []);
|
||||
|
||||
const isRecording = useCallback(() => {
|
||||
return Boolean(recorderState.isRecording);
|
||||
return recorderState.isRecording;
|
||||
}, [recorderState.isRecording]);
|
||||
|
||||
// Return stable object using useMemo
|
||||
|
||||
@@ -341,7 +341,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
return () => {
|
||||
attemptGuard.cancel();
|
||||
try {
|
||||
void mediaRecorder.current?.stop();
|
||||
mediaRecorder.current?.stop();
|
||||
} catch {
|
||||
// Ignore stop during unmount.
|
||||
}
|
||||
|
||||
@@ -332,7 +332,7 @@ export function useCommandCenter() {
|
||||
if (currentItems.length === 0) return;
|
||||
event.preventDefault();
|
||||
const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1));
|
||||
handleSelectItemRef.current(currentItems[index]!);
|
||||
handleSelectItemRef.current(currentItems[index]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => {
|
||||
const int16ToFloat32 = (input: Int16Array): Float32Array => {
|
||||
const out = new Float32Array(input.length);
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
out[i] = input[i]! / 32768;
|
||||
out[i] = input[i] / 32768;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -209,8 +209,8 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
const decodeAudioData = useCallback(
|
||||
async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
|
||||
const maybePromise = context.decodeAudioData(buffer);
|
||||
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
|
||||
return maybePromise as Promise<AudioBuffer>;
|
||||
if (maybePromise && typeof maybePromise.then === "function") {
|
||||
return maybePromise;
|
||||
}
|
||||
return await new Promise<AudioBuffer>((resolve, reject) => {
|
||||
context.decodeAudioData(buffer, resolve, reject);
|
||||
@@ -276,7 +276,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
},
|
||||
});
|
||||
|
||||
const stream = rawStream as MediaStream;
|
||||
const stream = rawStream;
|
||||
|
||||
const context = new AudioContextCtor();
|
||||
|
||||
@@ -347,7 +347,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
|
||||
const recorder = new RecorderCtor(stream, {
|
||||
mimeType: "audio/webm;codecs=opus",
|
||||
} as MediaRecorderOptions) as MediaRecorder;
|
||||
} as MediaRecorderOptions);
|
||||
|
||||
const recorderRefs: RecorderRefs = {
|
||||
recorder,
|
||||
|
||||
@@ -260,7 +260,7 @@ export function useKeyboardShortcuts({
|
||||
const handleCommandCenterToggle = (event: KeyboardEvent): boolean => {
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (!store.commandCenterOpen) {
|
||||
const target = event.target instanceof Element ? (event.target as Element) : null;
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const targetEl =
|
||||
target?.closest?.("textarea, input, [contenteditable='true']") ??
|
||||
(target instanceof HTMLElement ? target : null);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useLatchedBoolean } from "./use-latched-boolean";
|
||||
|
||||
describe("useLatchedBoolean", () => {
|
||||
it("stays true after the input first becomes true", () => {
|
||||
const { result, rerender } = renderHook(({ value }) => useLatchedBoolean(value), {
|
||||
initialProps: { value: false },
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
rerender({ value: true });
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
rerender({ value: false });
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLatchedBoolean(value: boolean): boolean {
|
||||
const [hasLatched, setHasLatched] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setHasLatched(true);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return hasLatched || value;
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export function usePrPaneData({
|
||||
const checkoutPrStatus = useCheckoutPrStatusQuery({ serverId, cwd, enabled });
|
||||
const status = checkoutPrStatus.status;
|
||||
const { prNumber, repoOwner, repoName } = extractPrRepoIdentity(status);
|
||||
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled !== false;
|
||||
const githubFeaturesEnabled = checkoutPrStatus.githubFeaturesEnabled;
|
||||
const unsupportedKey =
|
||||
prNumber === null ? null : timelineUnsupportedKey({ serverId, cwd, prNumber });
|
||||
const timelineUnsupported = unsupportedKey ? unsupportedTimelineKeys.has(unsupportedKey) : false;
|
||||
@@ -100,7 +100,7 @@ export function usePrPaneData({
|
||||
timelineUnsupported,
|
||||
});
|
||||
|
||||
const timelineQuery = useQuery<PullRequestTimeline, Error>({
|
||||
const timelineQuery = useQuery<PullRequestTimeline>({
|
||||
queryKey: prPaneTimelineQueryKey({ serverId, cwd, prNumber }),
|
||||
queryFn: async () => {
|
||||
if (!daemonClient || prNumber === null || repoOwner === null || repoName === null) {
|
||||
|
||||
@@ -252,7 +252,6 @@ describe("useProjects", () => {
|
||||
});
|
||||
expect(Object.keys(result.current.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
"hiddenUnsupportedRemoteCount",
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface ProjectHostError {
|
||||
|
||||
export interface UseProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hiddenUnsupportedRemoteCount: number;
|
||||
hostErrors: ProjectHostError[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
@@ -127,7 +126,6 @@ export function useProjects(): UseProjectsResult {
|
||||
|
||||
return {
|
||||
projects: projectsQuery.data?.projects ?? [],
|
||||
hiddenUnsupportedRemoteCount: projectsQuery.data?.hiddenUnsupportedRemoteCount ?? 0,
|
||||
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
||||
isLoading: projectsQuery.isLoading,
|
||||
isFetching: projectsQuery.isFetching,
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("use-settings", () => {
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
@@ -128,6 +129,7 @@ describe("use-settings", () => {
|
||||
expect(result).toEqual({
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
});
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
mod.APP_SETTINGS_KEY,
|
||||
@@ -166,6 +168,7 @@ describe("use-settings", () => {
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
@@ -184,6 +187,7 @@ describe("use-settings", () => {
|
||||
expect(result).toEqual({
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
import {
|
||||
DEFAULT_DESKTOP_SETTINGS,
|
||||
loadDesktopSettings,
|
||||
@@ -16,12 +17,15 @@ const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeName | "auto";
|
||||
sendBehavior: SendBehavior;
|
||||
serviceUrlBehavior: ServiceUrlBehavior;
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
@@ -32,6 +36,7 @@ export interface Settings extends AppSettings {
|
||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
@@ -114,6 +119,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
if (updates.sendBehavior !== undefined) {
|
||||
appUpdates.sendBehavior = updates.sendBehavior;
|
||||
}
|
||||
if (updates.serviceUrlBehavior !== undefined) {
|
||||
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
@@ -162,6 +170,15 @@ export function useSettings(): UseSettingsReturn {
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistAppSettings(updates: Partial<AppSettings>): Promise<void> {
|
||||
const current =
|
||||
appQueryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
|
||||
(await loadAppSettingsFromStorage());
|
||||
const next = { ...current, ...updates };
|
||||
appQueryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export async function loadAppSettingsFromStorage(): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
@@ -218,11 +235,17 @@ export async function loadSettingsFromStorage(): Promise<Settings> {
|
||||
function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
||||
result.theme = stored.theme as AppSettings["theme"];
|
||||
result.theme = stored.theme;
|
||||
}
|
||||
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
|
||||
result.sendBehavior = stored.sendBehavior;
|
||||
}
|
||||
if (
|
||||
typeof stored.serviceUrlBehavior === "string" &&
|
||||
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior)
|
||||
) {
|
||||
result.serviceUrlBehavior = stored.serviceUrlBehavior;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
@@ -288,6 +289,9 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
next.set(workspace.id, workspace);
|
||||
}
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
|
||||
|
||||
@@ -708,7 +708,10 @@ function ChatAgentContent({
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus === "online") {
|
||||
reconnectToastArmedRef.current = false;
|
||||
if (reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = false;
|
||||
panelToast.dismiss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (connectionStatus === "idle") {
|
||||
@@ -717,11 +720,11 @@ function ChatAgentContent({
|
||||
if (!reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = true;
|
||||
panelToast.api.show("Reconnecting...", {
|
||||
durationMs: 2200,
|
||||
durationMs: null,
|
||||
testID: "agent-reconnecting-toast",
|
||||
});
|
||||
}
|
||||
}, [connectionStatus, panelToast.api]);
|
||||
}, [connectionStatus, panelToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaneFocused || !agentId || !isConnected || !hasSession) {
|
||||
@@ -747,7 +750,7 @@ function ChatAgentContent({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isInitializing = agentId ? isInitializingFromMap !== false : false;
|
||||
const isInitializing = agentId ? isInitializingFromMap : false;
|
||||
const isHistorySyncing = useMemo(() => {
|
||||
if (!agentId || !isInitializing) {
|
||||
return false;
|
||||
|
||||
@@ -21,7 +21,7 @@ export function polyfillCrypto(): void {
|
||||
if (typeof g.TextEncoder !== "function") {
|
||||
class BufferTextEncoder {
|
||||
encode(input = ""): Uint8Array {
|
||||
return Uint8Array.from(Buffer.from(String(input), "utf8"));
|
||||
return Uint8Array.from(Buffer.from(input, "utf8"));
|
||||
}
|
||||
}
|
||||
g.TextEncoder = BufferTextEncoder as unknown as typeof TextEncoder;
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("useInlineReviewController", () => {
|
||||
body: "first comment",
|
||||
});
|
||||
|
||||
act(() => result.current.onEditComment(reviewTarget, savedComment!));
|
||||
act(() => result.current.onEditComment(reviewTarget, savedComment));
|
||||
expect(result.current.editor).toEqual({
|
||||
target: reviewTarget,
|
||||
commentId: savedComment?.id,
|
||||
@@ -208,8 +208,8 @@ describe("useInlineReviewController", () => {
|
||||
const updatedComment = useReviewDraftStore.getState().drafts[firstKey]?.[0];
|
||||
expect(updatedComment).toMatchObject({ id: savedComment?.id, body: "updated comment" });
|
||||
|
||||
act(() => result.current.onEditComment(reviewTarget, updatedComment!));
|
||||
act(() => result.current.onDeleteComment(updatedComment!.id));
|
||||
act(() => result.current.onEditComment(reviewTarget, updatedComment));
|
||||
act(() => result.current.onDeleteComment(updatedComment.id));
|
||||
expect(useReviewDraftStore.getState().drafts[firstKey]).toEqual([]);
|
||||
expect(result.current.editor).toBeNull();
|
||||
|
||||
|
||||
@@ -188,6 +188,21 @@ describe("DaemonStartService", () => {
|
||||
expect(service.getLastError()).toBeNull();
|
||||
});
|
||||
|
||||
it("recordError surfaces an external error and notifies subscribers", () => {
|
||||
const fake = createFakeStore();
|
||||
const service = new DaemonStartService({
|
||||
store: fake.store,
|
||||
startDesktopDaemon: async () => makeStatus(),
|
||||
});
|
||||
const notifications = vi.fn();
|
||||
service.subscribe(notifications);
|
||||
|
||||
service.recordError("settings file unreadable");
|
||||
|
||||
expect(service.getLastError()).toBe("settings file unreadable");
|
||||
expect(notifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops notifying after a subscriber unsubscribes", async () => {
|
||||
const fake = createFakeStore();
|
||||
let notifications = 0;
|
||||
|
||||
@@ -53,6 +53,10 @@ export class DaemonStartService {
|
||||
return this.lastError;
|
||||
}
|
||||
|
||||
recordError(message: string): void {
|
||||
this.setLastError(message);
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.inFlightCount > 0;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { isDev } from "@/constants/platform";
|
||||
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
@@ -453,7 +452,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
clientType: "mobile" as const,
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
runtimeGeneration,
|
||||
...(isDev ? { runtimeMetricsIntervalMs: 10_000 } : {}),
|
||||
};
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return new DaemonClient({
|
||||
|
||||
@@ -66,7 +66,6 @@ const { theme, projectsState, hostState, navigate, confirmDialogMock, client } =
|
||||
current: {
|
||||
projects: [],
|
||||
hostErrors: [] as ProjectHostError[],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -442,7 +441,6 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount: hosts.filter((host) => host.isOnline).length,
|
||||
githubUrl: "https://github.com/acme/app",
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -451,7 +449,6 @@ function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
||||
projectsState.current = {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -752,7 +749,7 @@ describe("ProjectSettingsScreen — save flow", () => {
|
||||
await flush();
|
||||
|
||||
expect(client.writeProjectConfig).toHaveBeenCalledTimes(1);
|
||||
const callArg = client.writeProjectConfig.mock.calls[0]![0] as {
|
||||
const callArg = client.writeProjectConfig.mock.calls[0][0] as {
|
||||
repoRoot: string;
|
||||
config: PaseoConfigRaw;
|
||||
expectedRevision: PaseoConfigRevision | null;
|
||||
@@ -767,7 +764,7 @@ describe("ProjectSettingsScreen — save flow", () => {
|
||||
// Subsequent saves use the freshly-returned revision.
|
||||
click(requireById("save-button"));
|
||||
await flush();
|
||||
const secondArg = client.writeProjectConfig.mock.calls[1]![0] as {
|
||||
const secondArg = client.writeProjectConfig.mock.calls[1][0] as {
|
||||
expectedRevision: PaseoConfigRevision;
|
||||
};
|
||||
expect(secondArg.expectedRevision).toEqual(newRevision);
|
||||
@@ -869,7 +866,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
||||
click(requireById("save-button"));
|
||||
await flush();
|
||||
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||
// string stays string when edited in place
|
||||
expect(savedConfig.worktree?.setup).toBe("npm install\nnpm run prepare");
|
||||
});
|
||||
@@ -897,7 +894,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
||||
click(requireById("save-button"));
|
||||
await flush();
|
||||
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||
expect(savedConfig.worktree?.teardown).toEqual(["docker compose down", "rm -rf .cache"]);
|
||||
});
|
||||
|
||||
@@ -920,7 +917,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
||||
click(requireById("save-button"));
|
||||
await flush();
|
||||
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||
expect(savedConfig.worktree?.setup).toBe("npm install");
|
||||
});
|
||||
|
||||
@@ -943,7 +940,7 @@ describe("ProjectSettingsScreen — round-trip semantics", () => {
|
||||
click(requireById("save-button"));
|
||||
await flush();
|
||||
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||
expect(savedConfig.worktree?.setup).toEqual(["npm install", "npm run prepare"]);
|
||||
});
|
||||
});
|
||||
@@ -996,7 +993,7 @@ describe("ProjectSettingsScreen — scripts editor", () => {
|
||||
click(requireById("save-button"));
|
||||
await flush();
|
||||
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||
const scriptKeys = Object.keys((savedConfig as Record<string, unknown>).scripts ?? {});
|
||||
// One of the two scripts was removed.
|
||||
expect(scriptKeys.length).toBe(1);
|
||||
@@ -1031,7 +1028,7 @@ describe("ProjectSettingsScreen — scripts editor", () => {
|
||||
click(requireById("save-button"));
|
||||
await flush();
|
||||
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0]![0]!.config as PaseoConfigRaw;
|
||||
const savedConfig = client.writeProjectConfig.mock.calls[0][0]!.config as PaseoConfigRaw;
|
||||
expect((savedConfig as Record<string, unknown>).customTopLevel).toBe("preserved");
|
||||
const worktreeRecord = (savedConfig.worktree ?? {}) as Record<string, unknown>;
|
||||
expect(worktreeRecord.customWorktreeField).toBe("keep");
|
||||
|
||||
@@ -32,7 +32,6 @@ const { theme, projectsState, navigate } = vi.hoisted(() => ({
|
||||
current: {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -231,7 +230,6 @@ function project(overrides: Partial<ProjectSummary> = {}): ProjectSummary {
|
||||
hostCount: hosts.length,
|
||||
onlineHostCount,
|
||||
githubUrl: "https://github.com/acme/app",
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -240,7 +238,6 @@ function setProjectsState(overrides: Partial<UseProjectsResult>) {
|
||||
projectsState.current = {
|
||||
projects: [],
|
||||
hostErrors: [],
|
||||
hiddenUnsupportedRemoteCount: 0,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
refetch: vi.fn(),
|
||||
@@ -343,19 +340,12 @@ describe("ProjectsScreen", () => {
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no projects", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 0 });
|
||||
setProjectsState({ projects: [] });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("No projects yet");
|
||||
});
|
||||
|
||||
it("renders the unsupported empty state when only non-GitHub remotes were filtered", () => {
|
||||
setProjectsState({ projects: [], hiddenUnsupportedRemoteCount: 3 });
|
||||
|
||||
render({ kind: "projects" });
|
||||
|
||||
expect(container?.textContent).toContain("Non-GitHub remote projects aren't supported yet");
|
||||
expect(container?.textContent).not.toContain("Non-GitHub remote projects aren't supported yet");
|
||||
});
|
||||
|
||||
it("renders a partial-host-failure banner above the list, naming each failed host", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ProjectsScreenProps {
|
||||
}
|
||||
|
||||
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
const { projects, hostErrors, hiddenUnsupportedRemoteCount, isLoading } = useProjects();
|
||||
const { projects, hostErrors, isLoading } = useProjects();
|
||||
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
||||
|
||||
if (isLoading && projects.length === 0) {
|
||||
@@ -27,13 +27,9 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
const message =
|
||||
hiddenUnsupportedRemoteCount > 0
|
||||
? "Non-GitHub remote projects aren't supported yet"
|
||||
: "No projects yet";
|
||||
return (
|
||||
<View style={styles.centered} testID="projects-list">
|
||||
<Text style={styles.emptyText}>{message}</Text>
|
||||
<Text style={styles.emptyText}>No projects yet</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
useSettings,
|
||||
type AppSettings,
|
||||
type SendBehavior,
|
||||
type ServiceUrlBehavior,
|
||||
type Settings as EffectiveSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import { THEME_SWATCHES } from "@/styles/theme";
|
||||
@@ -190,14 +191,24 @@ const RELEASE_CHANNEL_OPTIONS = [
|
||||
{ value: "beta" as const, label: "Beta" },
|
||||
];
|
||||
|
||||
const SERVICE_URL_BEHAVIOR_LABELS: Record<ServiceUrlBehavior, string> = {
|
||||
ask: "Ask",
|
||||
"in-app": "In Paseo",
|
||||
external: "External browser",
|
||||
};
|
||||
|
||||
const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GeneralSectionProps {
|
||||
settings: AppSettings;
|
||||
isDesktopApp: boolean;
|
||||
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
||||
}
|
||||
|
||||
interface ThemeMenuItemProps {
|
||||
@@ -229,10 +240,33 @@ function ThemeMenuItem({
|
||||
);
|
||||
}
|
||||
|
||||
interface ServiceUrlBehaviorMenuItemProps {
|
||||
value: ServiceUrlBehavior;
|
||||
selected: boolean;
|
||||
onChange: (value: ServiceUrlBehavior) => void;
|
||||
}
|
||||
|
||||
function ServiceUrlBehaviorMenuItem({
|
||||
value,
|
||||
selected,
|
||||
onChange,
|
||||
}: ServiceUrlBehaviorMenuItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(value);
|
||||
}, [onChange, value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{SERVICE_URL_BEHAVIOR_LABELS[value]}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralSection({
|
||||
settings,
|
||||
isDesktopApp,
|
||||
handleThemeChange,
|
||||
handleSendBehaviorChange,
|
||||
handleServiceUrlBehaviorChange,
|
||||
}: GeneralSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const iconSize = theme.iconSize.md;
|
||||
@@ -290,6 +324,32 @@ function GeneralSection({
|
||||
options={SEND_BEHAVIOR_OPTIONS}
|
||||
/>
|
||||
</View>
|
||||
{isDesktopApp ? (
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Service URLs</Text>
|
||||
<Text style={settingsStyles.rowHint}>Where to open URLs from running scripts</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger style={themeTriggerStyle}>
|
||||
<Text style={styles.themeTriggerText}>
|
||||
{SERVICE_URL_BEHAVIOR_LABELS[settings.serviceUrlBehavior]}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{SERVICE_URL_BEHAVIOR_VALUES.map((value) => (
|
||||
<ServiceUrlBehaviorMenuItem
|
||||
key={value}
|
||||
value={value}
|
||||
selected={settings.serviceUrlBehavior === value}
|
||||
onChange={handleServiceUrlBehaviorChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -768,6 +828,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleServiceUrlBehaviorChange = useCallback(
|
||||
(behavior: ServiceUrlBehavior) => {
|
||||
void updateSettings({ serviceUrlBehavior: behavior });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handlePlaybackTest = useCallback(async () => {
|
||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||
return;
|
||||
@@ -949,8 +1016,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
return (
|
||||
<GeneralSection
|
||||
settings={settings}
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleThemeChange={handleThemeChange}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
/>
|
||||
);
|
||||
case "shortcuts":
|
||||
|
||||
@@ -44,7 +44,12 @@ describe("workspace bulk close helpers", () => {
|
||||
expect(groups).toEqual({
|
||||
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
|
||||
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
|
||||
otherTabs: [{ tabId: "file_/repo/README.md" }],
|
||||
otherTabs: [
|
||||
{
|
||||
tabId: "file_/repo/README.md",
|
||||
target: { kind: "file", path: "/repo/README.md" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,7 +124,7 @@ describe("workspace bulk close helpers", () => {
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -158,7 +163,7 @@ describe("workspace bulk close helpers", () => {
|
||||
expect(cleanupCalls).toEqual([
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
export interface BulkClosableTabGroups {
|
||||
agentTabs: Array<{ tabId: string; agentId: string }>;
|
||||
terminalTabs: Array<{ tabId: string; terminalId: string }>;
|
||||
otherTabs: Array<{ tabId: string }>;
|
||||
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
|
||||
}
|
||||
|
||||
interface CloseWorkspaceTabWithCleanupInput {
|
||||
@@ -37,7 +37,7 @@ export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkCl
|
||||
groups.terminalTabs.push({ tabId: tab.tabId, terminalId: tab.target.terminalId });
|
||||
continue;
|
||||
}
|
||||
groups.otherTabs.push({ tabId: tab.tabId });
|
||||
groups.otherTabs.push({ tabId: tab.tabId, target: tab.target });
|
||||
}
|
||||
|
||||
return groups;
|
||||
@@ -103,9 +103,9 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId } of groups.otherTabs) {
|
||||
for (const { tabId, target } of groups.otherTabs) {
|
||||
void closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({ tabId });
|
||||
closeWorkspaceTabWithCleanup({ tabId, target });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,21 +41,11 @@ export function renderWorkspaceRouteGate(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export function renderWorkspaceReconnectIndicator(input: {
|
||||
state: WorkspaceRouteState;
|
||||
onRetryHost: () => void;
|
||||
}): React.ReactNode {
|
||||
if (input.state.kind !== "reconnecting") {
|
||||
return null;
|
||||
}
|
||||
return <WorkspaceReconnectBanner state={input.state} onRetry={input.onRetryHost} />;
|
||||
}
|
||||
|
||||
function getWorkspaceHostStateTitle(
|
||||
state: Extract<WorkspaceRouteState, { kind: "unreachable" }>,
|
||||
): string {
|
||||
if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") {
|
||||
return `Connecting to ${state.hostName}`;
|
||||
return "Connecting";
|
||||
}
|
||||
if (state.connectionStatus === "offline") {
|
||||
return `${state.hostName} is offline`;
|
||||
@@ -63,15 +53,6 @@ function getWorkspaceHostStateTitle(
|
||||
return `Cannot reach ${state.hostName}`;
|
||||
}
|
||||
|
||||
function getReconnectBannerCopy(
|
||||
state: Extract<WorkspaceRouteState, { kind: "reconnecting" }>,
|
||||
): string {
|
||||
if (state.connectionStatus === "connecting" || state.connectionStatus === "idle") {
|
||||
return `Reconnecting to ${state.hostName}...`;
|
||||
}
|
||||
return `${state.hostName} is offline`;
|
||||
}
|
||||
|
||||
function WorkspaceConnecting({ hostName }: { hostName: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -106,7 +87,9 @@ function WorkspaceUnreachable({
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{getWorkspaceHostStateTitle(state)}</Text>
|
||||
<Text style={styles.description}>
|
||||
Host status: {formatConnectionStatus(state.connectionStatus)}
|
||||
{state.connectionStatus === "connecting" || state.connectionStatus === "idle"
|
||||
? state.hostName
|
||||
: `Host status: ${formatConnectionStatus(state.connectionStatus)}`}
|
||||
</Text>
|
||||
{state.lastError ? (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -135,33 +118,6 @@ function WorkspaceUnreachable({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceReconnectBanner({
|
||||
state,
|
||||
onRetry,
|
||||
}: {
|
||||
state: Extract<WorkspaceRouteState, { kind: "reconnecting" }>;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error";
|
||||
const showSpinner = state.connectionStatus === "connecting" || state.connectionStatus === "idle";
|
||||
const isErrorState = state.connectionStatus === "offline" || state.connectionStatus === "error";
|
||||
|
||||
return (
|
||||
<View style={styles.reconnectBanner} testID="workspace-reconnect-banner">
|
||||
{showSpinner ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
|
||||
<Text style={isErrorState ? styles.reconnectTextDestructive : styles.reconnectText}>
|
||||
{getReconnectBannerCopy(state)}
|
||||
</Text>
|
||||
{canRetry ? (
|
||||
<Button size="sm" variant="outline" leftIcon={RotateCw} onPress={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
@@ -220,28 +176,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
reconnectBanner: {
|
||||
minHeight: 32,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
reconnectText: {
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flexShrink: 1,
|
||||
},
|
||||
reconnectTextDestructive: {
|
||||
minWidth: 0,
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -97,7 +97,8 @@ import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { createWorkspaceBrowser } from "@/stores/browser-store";
|
||||
import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -128,10 +129,7 @@ import {
|
||||
resolveWorkspaceRouteState,
|
||||
type WorkspaceRouteState,
|
||||
} from "@/screens/workspace/workspace-route-state";
|
||||
import {
|
||||
renderWorkspaceReconnectIndicator,
|
||||
renderWorkspaceRouteGate,
|
||||
} from "@/screens/workspace/workspace-route-state-views";
|
||||
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
|
||||
import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
workspaceAgentVisibilityEqual,
|
||||
@@ -1261,7 +1259,7 @@ function renderWorkspaceScreenGateShell(input: {
|
||||
|
||||
return (
|
||||
<WorkspaceFocusProvider workspaceKey={input.workspaceKey}>
|
||||
<View style={containerWithWorkspaceBackgroundStyle}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
<WorkspaceScreenGateFrame>{input.gate}</WorkspaceScreenGateFrame>
|
||||
@@ -1543,7 +1541,7 @@ function WorkspaceScreenContent({
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
||||
return await client.getCheckoutStatus(workspaceDirectory);
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
@@ -1785,6 +1783,11 @@ function WorkspaceScreenContent({
|
||||
unpinWorkspaceAgent(persistenceKey, input.target.agentId);
|
||||
hideWorkspaceAgent(persistenceKey, input.target.agentId);
|
||||
}
|
||||
if (input.target?.kind === "browser") {
|
||||
const { browserId } = input.target;
|
||||
useBrowserStore.getState().removeBrowser(browserId);
|
||||
void getDesktopHost()?.browser?.clearPartition?.(browserId);
|
||||
}
|
||||
closeWorkspaceTab(persistenceKey, normalizedTabId);
|
||||
},
|
||||
[closeWorkspaceTab, hideWorkspaceAgent, persistenceKey, unpinWorkspaceAgent],
|
||||
@@ -2160,6 +2163,17 @@ function WorkspaceScreenContent({
|
||||
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
|
||||
const handleOpenUrlInBrowserTab = useCallback(
|
||||
(url: string) => {
|
||||
if (!persistenceKey || !getIsElectron()) {
|
||||
return;
|
||||
}
|
||||
const { browserId } = createWorkspaceBrowser({ initialUrl: url });
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "browser", browserId });
|
||||
},
|
||||
[openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
|
||||
const handleSelectSwitcherTab = useCallback(
|
||||
(key: string) => {
|
||||
navigateToTabId(key);
|
||||
@@ -2271,11 +2285,14 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
|
||||
const handleCloseDraftOrFileTab = useCallback(
|
||||
function handleCloseDraftOrFileTab(tabId: string) {
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
function handleCloseDraftOrFileTab(input: {
|
||||
tabId: string;
|
||||
target?: WorkspaceTabTarget | null;
|
||||
}) {
|
||||
setHoveredTabKey((current) => (current === input.tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({ tabId });
|
||||
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
|
||||
}
|
||||
},
|
||||
[closeWorkspaceTabWithCleanup, persistenceKey],
|
||||
@@ -2295,7 +2312,7 @@ function WorkspaceScreenContent({
|
||||
await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
|
||||
return;
|
||||
}
|
||||
handleCloseDraftOrFileTab(tabId);
|
||||
handleCloseDraftOrFileTab({ tabId, target: tab.target });
|
||||
},
|
||||
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
|
||||
);
|
||||
@@ -2909,10 +2926,6 @@ function WorkspaceScreenContent({
|
||||
gate: workspaceScreenGate,
|
||||
workspaceKey: persistenceKey,
|
||||
});
|
||||
const reconnectBanner = renderWorkspaceReconnectIndicator({
|
||||
state: workspaceRouteState,
|
||||
onRetryHost: handleRetryHost,
|
||||
});
|
||||
|
||||
const headerRight = useMemo(
|
||||
() => (
|
||||
@@ -2925,6 +2938,7 @@ function WorkspaceScreenContent({
|
||||
liveTerminalIds={liveTerminalIds}
|
||||
onScriptTerminalStarted={handleScriptTerminalStarted}
|
||||
onViewTerminal={handleViewScriptTerminal}
|
||||
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
) : null}
|
||||
@@ -3041,6 +3055,7 @@ function WorkspaceScreenContent({
|
||||
liveTerminalIds,
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
handleOpenUrlInBrowserTab,
|
||||
showCompactButtonLabels,
|
||||
isGitCheckout,
|
||||
handleToggleExplorer,
|
||||
@@ -3153,7 +3168,6 @@ function WorkspaceScreenContent({
|
||||
/>
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
{reconnectBanner}
|
||||
{showScreenHeader && (
|
||||
<ScreenHeader
|
||||
onRowLayout={onHeaderLayout}
|
||||
|
||||
@@ -107,6 +107,7 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
: children}
|
||||
</button>
|
||||
),
|
||||
useDropdownMenuClose: () => () => {},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
useDropdownMenuClose,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { openServiceUrl } from "@/utils/open-service-url";
|
||||
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
@@ -28,6 +29,7 @@ interface WorkspaceScriptsButtonProps {
|
||||
liveTerminalIds?: readonly string[];
|
||||
onScriptTerminalStarted?: (terminalId: string) => void;
|
||||
onViewTerminal?: (terminalId: string) => void;
|
||||
onOpenUrlInBrowserTab?: (url: string) => void;
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
@@ -136,6 +138,7 @@ interface HostLinkProps {
|
||||
label: string;
|
||||
url: string | null;
|
||||
scriptName: string;
|
||||
onOpenInBrowserTab?: (url: string) => void;
|
||||
}
|
||||
|
||||
interface HostLinkChildrenProps {
|
||||
@@ -161,15 +164,18 @@ function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps):
|
||||
);
|
||||
}
|
||||
|
||||
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
|
||||
function HostLinkRow({ label, url, scriptName, onOpenInBrowserTab }: HostLinkProps): ReactElement {
|
||||
const disabled = !url;
|
||||
const closeMenu = useDropdownMenuClose();
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
if (url) void openExternalUrl(url);
|
||||
if (!url) return;
|
||||
closeMenu();
|
||||
void openServiceUrl(url, { openInApp: onOpenInBrowserTab });
|
||||
},
|
||||
[url],
|
||||
[url, onOpenInBrowserTab, closeMenu],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
@@ -219,6 +225,7 @@ interface ScriptRowProps {
|
||||
isStartPending: boolean;
|
||||
onStartScript: (scriptName: string) => void;
|
||||
onViewTerminal?: (terminalId: string) => void;
|
||||
onOpenUrlInBrowserTab?: (url: string) => void;
|
||||
}
|
||||
|
||||
function resolveScriptIconColorMapping(args: {
|
||||
@@ -244,6 +251,7 @@ function ScriptRow({
|
||||
isStartPending,
|
||||
onStartScript,
|
||||
onViewTerminal,
|
||||
onOpenUrlInBrowserTab,
|
||||
}: ScriptRowProps): ReactElement {
|
||||
const isRunning = script.lifecycle === "running";
|
||||
const isService = (script.type ?? "service") === "service";
|
||||
@@ -340,6 +348,7 @@ function ScriptRow({
|
||||
label={link.label}
|
||||
url={link.url}
|
||||
scriptName={script.scriptName}
|
||||
onOpenInBrowserTab={onOpenUrlInBrowserTab}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -355,6 +364,7 @@ export function WorkspaceScriptsButton({
|
||||
liveTerminalIds = [],
|
||||
onScriptTerminalStarted,
|
||||
onViewTerminal,
|
||||
onOpenUrlInBrowserTab,
|
||||
hideLabels,
|
||||
}: WorkspaceScriptsButtonProps): ReactElement | null {
|
||||
const toast = useToast();
|
||||
@@ -438,6 +448,7 @@ export function WorkspaceScriptsButton({
|
||||
isStartPending={startScriptMutation.isPending}
|
||||
onStartScript={handleStartScript}
|
||||
onViewTerminal={onViewTerminal}
|
||||
onOpenUrlInBrowserTab={onOpenUrlInBrowserTab}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user